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,45 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "AffiliateProgramSetupScreen",
module_name = "AffiliateProgramSetupScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/TelegramStringFormatting",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/UndoUI",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListItemSliderSelectorComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/Components/BlurredBackgroundComponent",
"//submodules/Markdown",
"//submodules/PresentationDataUtils",
"//submodules/TelegramUI/Components/Stories/PeerListItemComponent",
"//submodules/ContextUI",
"//submodules/TelegramUI/Components/AlertComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/TelegramUI/Components/ToastComponent",
"//submodules/AvatarNode",
"//submodules/TelegramUI/Components/Premium/PremiumCoinComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,334 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import MultilineTextComponent
import AlertComponent
final class TableComponent: CombinedComponent {
class Item: Equatable {
public let id: AnyHashable
public let title: String
public let component: AnyComponent<Empty>
public let insets: UIEdgeInsets?
public init<IdType: Hashable>(id: IdType, title: String, component: AnyComponent<Empty>, insets: UIEdgeInsets? = nil) {
self.id = AnyHashable(id)
self.title = title
self.component = component
self.insets = insets
}
public static func == (lhs: Item, rhs: Item) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.component != rhs.component {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
}
private let theme: PresentationTheme
private let items: [Item]
public init(theme: PresentationTheme, items: [Item]) {
self.theme = theme
self.items = items
}
public static func ==(lhs: TableComponent, rhs: TableComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.items != rhs.items {
return false
}
return true
}
final class State: ComponentState {
var cachedBorderImage: (UIImage, PresentationTheme)?
}
func makeState() -> State {
return State()
}
public static var body: Body {
let leftColumnBackground = Child(Rectangle.self)
let verticalBorder = Child(Rectangle.self)
let titleChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let valueChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let borderChildren = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let outerBorder = Child(Image.self)
return { context in
let verticalPadding: CGFloat = 11.0
let horizontalPadding: CGFloat = 12.0
let borderWidth: CGFloat = 1.0
let backgroundColor = context.component.theme.actionSheet.opaqueItemBackgroundColor
let borderColor = backgroundColor.mixedWith(context.component.theme.list.itemBlocksSeparatorColor, alpha: 0.6)
var leftColumnWidth: CGFloat = 0.0
var updatedTitleChildren: [_UpdatedChildComponent] = []
var updatedValueChildren: [(_UpdatedChildComponent, UIEdgeInsets)] = []
var updatedBorderChildren: [_UpdatedChildComponent] = []
for item in context.component.items {
let titleChild = titleChildren[item.id].update(
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: item.title, font: Font.regular(15.0), textColor: context.component.theme.list.itemPrimaryTextColor))
)),
availableSize: context.availableSize,
transition: context.transition
)
updatedTitleChildren.append(titleChild)
if titleChild.size.width > leftColumnWidth {
leftColumnWidth = titleChild.size.width
}
}
leftColumnWidth = max(100.0, leftColumnWidth + horizontalPadding * 2.0)
let rightColumnWidth = context.availableSize.width - leftColumnWidth
var i = 0
var rowHeights: [Int: CGFloat] = [:]
var totalHeight: CGFloat = 0.0
for item in context.component.items {
let titleChild = updatedTitleChildren[i]
let insets: UIEdgeInsets
if let customInsets = item.insets {
insets = customInsets
} else {
insets = UIEdgeInsets(top: 0.0, left: horizontalPadding, bottom: 0.0, right: horizontalPadding)
}
let valueChild = valueChildren[item.id].update(
component: item.component,
availableSize: CGSize(width: rightColumnWidth - insets.left - insets.right, height: context.availableSize.height),
transition: context.transition
)
updatedValueChildren.append((valueChild, insets))
let rowHeight = max(40.0, max(titleChild.size.height, valueChild.size.height) + verticalPadding * 2.0)
rowHeights[i] = rowHeight
totalHeight += rowHeight
if i < context.component.items.count - 1 {
let borderChild = borderChildren[item.id].update(
component: AnyComponent(Rectangle(color: borderColor)),
availableSize: CGSize(width: context.availableSize.width, height: borderWidth),
transition: context.transition
)
updatedBorderChildren.append(borderChild)
}
i += 1
}
let leftColumnBackground = leftColumnBackground.update(
component: Rectangle(color: context.component.theme.list.itemInputField.backgroundColor),
availableSize: CGSize(width: leftColumnWidth, height: totalHeight),
transition: context.transition
)
context.add(
leftColumnBackground
.position(CGPoint(x: leftColumnWidth / 2.0, y: totalHeight / 2.0))
)
let borderImage: UIImage
if let (currentImage, theme) = context.state.cachedBorderImage, theme === context.component.theme {
borderImage = currentImage
} else {
let borderRadius: CGFloat = 5.0
borderImage = generateImage(CGSize(width: 16.0, height: 16.0), rotatedContext: { size, context in
let bounds = CGRect(origin: .zero, size: size)
context.setFillColor(backgroundColor.cgColor)
context.fill(bounds)
let path = CGPath(roundedRect: bounds.insetBy(dx: borderWidth / 2.0, dy: borderWidth / 2.0), cornerWidth: borderRadius, cornerHeight: borderRadius, transform: nil)
context.setBlendMode(.clear)
context.addPath(path)
context.fillPath()
context.setBlendMode(.normal)
context.setStrokeColor(borderColor.cgColor)
context.setLineWidth(borderWidth)
context.addPath(path)
context.strokePath()
})!.stretchableImage(withLeftCapWidth: 5, topCapHeight: 5)
context.state.cachedBorderImage = (borderImage, context.component.theme)
}
let outerBorder = outerBorder.update(
component: Image(image: borderImage),
availableSize: CGSize(width: context.availableSize.width, height: totalHeight),
transition: context.transition
)
context.add(outerBorder
.position(CGPoint(x: context.availableSize.width / 2.0, y: totalHeight / 2.0))
)
let verticalBorder = verticalBorder.update(
component: Rectangle(color: borderColor),
availableSize: CGSize(width: borderWidth, height: totalHeight),
transition: context.transition
)
context.add(
verticalBorder
.position(CGPoint(x: leftColumnWidth - borderWidth / 2.0, y: totalHeight / 2.0))
)
i = 0
var originY: CGFloat = 0.0
for (titleChild, (valueChild, valueInsets)) in zip(updatedTitleChildren, updatedValueChildren) {
let rowHeight = rowHeights[i] ?? 0.0
let titleFrame = CGRect(origin: CGPoint(x: horizontalPadding, y: originY + verticalPadding), size: titleChild.size)
let valueFrame = CGRect(origin: CGPoint(x: leftColumnWidth + valueInsets.left, y: originY + verticalPadding), size: valueChild.size)
context.add(titleChild
.position(titleFrame.center)
)
context.add(valueChild
.position(valueFrame.center)
)
if i < updatedBorderChildren.count {
let borderChild = updatedBorderChildren[i]
context.add(borderChild
.position(CGPoint(x: context.availableSize.width / 2.0, y: originY + rowHeight - borderWidth / 2.0))
)
}
originY += rowHeight
i += 1
}
return CGSize(width: context.availableSize.width, height: totalHeight)
}
}
}
private final class TableAlertContentComponent: CombinedComponent {
let theme: PresentationTheme
let title: String
let text: String
let table: TableComponent
init(theme: PresentationTheme, title: String, text: String, table: TableComponent) {
self.theme = theme
self.title = title
self.text = text
self.table = table
}
static func ==(lhs: TableAlertContentComponent, rhs: TableAlertContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.table != rhs.table {
return false
}
return true
}
public static var body: Body {
let title = Child(MultilineTextComponent.self)
let text = Child(MultilineTextComponent.self)
let table = Child(TableComponent.self)
return { context in
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.title, font: Font.semibold(16.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center
),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.text, font: Font.regular(13.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
let table = table.update(
component: context.component.table,
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
var size = CGSize(width: 0.0, height: 0.0)
size.width = max(size.width, title.size.width)
size.width = max(size.width, text.size.width)
size.width = max(size.width, table.size.width)
size.height += title.size.height
size.height += 5.0
size.height += text.size.height
size.height += 14.0
size.height += table.size.height
size.height -= 3.0
var contentHeight: CGFloat = 0.0
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - title.size.width) * 0.5), y: contentHeight), size: title.size)
contentHeight += title.size.height + 5.0
let textFrame = CGRect(origin: CGPoint(x: floor((size.width - text.size.width) * 0.5), y: contentHeight), size: text.size)
contentHeight += text.size.height + 14.0
let tableFrame = CGRect(origin: CGPoint(x: floor((size.width - table.size.width) * 0.5), y: contentHeight), size: table.size)
contentHeight += table.size.height
context.add(title
.position(titleFrame.center)
)
context.add(text
.position(textFrame.center)
)
context.add(table
.position(tableFrame.center)
)
return size
}
}
}
func tableAlert(theme: PresentationTheme, title: String, text: String, table: TableComponent, actions: [ComponentAlertAction]) -> ViewController {
return componentAlertController(
theme: AlertControllerTheme(presentationTheme: theme, fontSize: .regular),
content: AnyComponent(TableAlertContentComponent(
theme: theme,
title: title,
text: text,
table: table
)),
actions: actions,
actionLayout: .horizontal
)
}
@@ -0,0 +1,28 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "CollectionTabItemComponent",
module_name = "CollectionTabItemComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Display",
"//submodules/TelegramCore",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/ComponentFlow",
"//submodules/TextFormat",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,168 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import TelegramPresentationData
import MultilineTextComponent
import BundleIconComponent
import TabSelectorComponent
import EmojiTextAttachmentView
import TextFormat
import AccountContext
public final class CollectionTabItemComponent: Component {
public typealias EnvironmentType = TabSelectorComponent.ItemEnvironment
public enum Icon: Equatable {
case collection(TelegramMediaFile)
case add
}
public let context: AccountContext
public let icon: Icon?
public let title: String
public let theme: PresentationTheme
public init(
context: AccountContext,
icon: Icon?,
title: String,
theme: PresentationTheme
) {
self.context = context
self.icon = icon
self.title = title
self.theme = theme
}
public static func ==(lhs: CollectionTabItemComponent, rhs: CollectionTabItemComponent) -> Bool {
if lhs.icon != rhs.icon {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
public final class View: UIView {
private let title = ComponentView<Empty>()
private let icon = ComponentView<Empty>()
private var iconLayer: InlineStickerItemLayer?
private var component: CollectionTabItemComponent?
func update(component: CollectionTabItemComponent, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
let environment = environment[EnvironmentType.self].value
let iconSpacing: CGFloat = 3.0
let normalColor = component.theme.list.itemSecondaryTextColor
let selectedColor = component.theme.list.freeTextColor
let effectiveColor = normalColor.mixedWith(selectedColor, alpha: environment.selectionFraction)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.medium(14.0), textColor: effectiveColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
var iconOffset: CGFloat = 0.0
var iconSize = CGSize()
if let icon = component.icon {
switch icon {
case let .collection(file):
iconSize = CGSize(width: 16.0, height: 16.0)
let iconLayer: InlineStickerItemLayer
if let current = self.iconLayer {
iconLayer = current
} else {
iconLayer = InlineStickerItemLayer(
context: component.context,
userLocation: .other,
attemptSynchronousLoad: true,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file),
file: file,
cache: component.context.animationCache,
renderer: component.context.animationRenderer,
placeholderColor: component.theme.list.mediaPlaceholderColor,
pointSize: iconSize,
loopCount: 1
)
self.layer.addSublayer(iconLayer)
self.iconLayer = iconLayer
}
let iconFrame = CGRect(origin: CGPoint(x: iconOffset, y: floorToScreenPixels((titleSize.height - iconSize.height) * 0.5)), size: iconSize)
iconLayer.frame = iconFrame
case .add:
iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: "Chat/Input/Media/PanelBadgeAdd",
tintColor: component.theme.list.itemSecondaryTextColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: iconOffset, y: floorToScreenPixels((titleSize.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
iconView.isUserInteractionEnabled = false
self.addSubview(iconView)
}
iconView.frame = iconFrame
}
}
iconOffset += iconSize.width + iconSpacing
} else {
if let iconLayer = self.iconLayer {
self.iconLayer = nil
iconLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
iconLayer.removeFromSuperlayer()
})
iconLayer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false)
}
if let iconView = self.icon.view {
iconView.removeFromSuperview()
}
}
let titleFrame = CGRect(origin: CGPoint(x: iconOffset, y: 0.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
titleView.frame = titleFrame
}
let size: CGSize
if let _ = component.icon {
size = CGSize(width: iconSize.width + iconSpacing + titleSize.width, height: titleSize.height)
} else {
size = titleSize
}
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,31 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "MessagePriceItem",
module_name = "MessagePriceItem",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/Display",
"//submodules/Postbox",
"//submodules/TelegramCore",
"//submodules/PresentationDataUtils",
"//submodules/TelegramPresentationData",
"//submodules/ItemListUI",
"//submodules/LegacyComponents",
"//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/ListItemComponentAdaptor",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,535 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import LegacyComponents
import ItemListUI
import PresentationDataUtils
import ComponentFlow
import ButtonComponent
import BundleIconComponent
import MultilineTextComponent
import ListItemComponentAdaptor
private let textFont = Font.with(size: 17.0, traits: .monospacedNumbers)
private let smallTextFont = Font.with(size: 13.0, traits: .monospacedNumbers)
public final class MessagePriceItem: Equatable, ListViewItem, ItemListItem, ListItemComponentAdaptor.ItemGenerator {
let theme: PresentationTheme
let strings: PresentationStrings
let systemStyle: ItemListSystemStyle
let isEnabled: Bool
let minValue: Int64
let maxValue: Int64
let value: Int64
let price: String
public let sectionId: ItemListSectionId
let updated: (Int64, Bool) -> Void
let openSetCustom: (() -> Void)?
let openPremiumInfo: (() -> Void)?
public init(theme: PresentationTheme, strings: PresentationStrings, systemStyle: ItemListSystemStyle = .legacy, isEnabled: Bool, minValue: Int64, maxValue: Int64, value: Int64, price: String, sectionId: ItemListSectionId, updated: @escaping (Int64, Bool) -> Void, openSetCustom: (() -> Void)? = nil, openPremiumInfo: (() -> Void)? = nil) {
self.theme = theme
self.strings = strings
self.systemStyle = systemStyle
self.isEnabled = isEnabled
self.minValue = minValue
self.maxValue = maxValue
self.value = value
self.price = price
self.sectionId = sectionId
self.updated = updated
self.openSetCustom = openSetCustom
self.openPremiumInfo = openPremiumInfo
}
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = MessagePriceItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? MessagePriceItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
public func item() -> ListViewItem {
return self
}
public static func ==(lhs: MessagePriceItem, rhs: MessagePriceItem) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.minValue != rhs.minValue {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.price != rhs.price {
return false
}
if (lhs.openSetCustom == nil) != (rhs.openSetCustom == nil) {
return false
}
if (lhs.openPremiumInfo == nil) != (rhs.openPremiumInfo == nil) {
return false
}
return true
}
}
private class MessagePriceItemNode: ListViewItemNode {
private struct Amount: Equatable {
private let sliderSteps: [Int]
private let minRealValue: Int
private let maxRealValue: Int
let maxSliderValue: Int
private let isLogarithmic: Bool
private(set) var realValue: Int
private(set) var sliderValue: Int
private static func makeSliderSteps(minRealValue: Int, maxRealValue: Int, isLogarithmic: Bool) -> [Int] {
if isLogarithmic {
var sliderSteps: [Int] = [ minRealValue, 10, 50, 100, 500, 1_000, 2_000, 5_000, 7_500, 10_000 ]
sliderSteps.removeAll(where: { $0 >= maxRealValue })
sliderSteps.append(maxRealValue)
return sliderSteps
} else {
return [1, maxRealValue]
}
}
private static func remapValueToSlider(realValue: Int, maxSliderValue: Int, steps: [Int]) -> Int {
guard realValue >= steps.first!, realValue <= steps.last! else { return 0 }
for i in 0 ..< steps.count - 1 {
if realValue >= steps[i] && realValue <= steps[i + 1] {
let range = steps[i + 1] - steps[i]
let relativeValue = realValue - steps[i]
let stepFraction = Float(relativeValue) / Float(range)
return Int(Float(i) * Float(maxSliderValue) / Float(steps.count - 1)) + Int(stepFraction * Float(maxSliderValue) / Float(steps.count - 1))
}
}
return maxSliderValue
}
private static func remapSliderToValue(sliderValue: Int, maxSliderValue: Int, steps: [Int]) -> Int {
guard sliderValue >= 0, sliderValue <= maxSliderValue else { return steps.first! }
let stepIndex = Int(Float(sliderValue) / Float(maxSliderValue) * Float(steps.count - 1))
let fraction = Float(sliderValue) / Float(maxSliderValue) * Float(steps.count - 1) - Float(stepIndex)
if stepIndex >= steps.count - 1 {
return steps.last!
} else {
let range = steps[stepIndex + 1] - steps[stepIndex]
return steps[stepIndex] + Int(fraction * Float(range))
}
}
init(realValue: Int, minRealValue: Int, maxRealValue: Int, maxSliderValue: Int, isLogarithmic: Bool) {
self.sliderSteps = Amount.makeSliderSteps(minRealValue: minRealValue, maxRealValue: maxRealValue, isLogarithmic: isLogarithmic)
self.minRealValue = minRealValue
self.maxRealValue = maxRealValue
self.maxSliderValue = maxSliderValue
self.isLogarithmic = isLogarithmic
self.realValue = realValue
self.sliderValue = Amount.remapValueToSlider(realValue: self.realValue, maxSliderValue: self.maxSliderValue, steps: self.sliderSteps)
}
init(sliderValue: Int, minRealValue: Int, maxRealValue: Int, maxSliderValue: Int, isLogarithmic: Bool) {
self.sliderSteps = Amount.makeSliderSteps(minRealValue: minRealValue, maxRealValue: maxRealValue, isLogarithmic: isLogarithmic)
self.minRealValue = minRealValue
self.maxRealValue = maxRealValue
self.maxSliderValue = maxSliderValue
self.isLogarithmic = isLogarithmic
self.sliderValue = sliderValue
self.realValue = Amount.remapSliderToValue(sliderValue: self.sliderValue, maxSliderValue: self.maxSliderValue, steps: self.sliderSteps)
}
func withRealValue(_ realValue: Int) -> Amount {
return Amount(realValue: realValue, minRealValue: self.minRealValue, maxRealValue: self.maxRealValue, maxSliderValue: self.maxSliderValue, isLogarithmic: self.isLogarithmic)
}
func withSliderValue(_ sliderValue: Int) -> Amount {
return Amount(sliderValue: sliderValue, minRealValue: self.minRealValue, maxRealValue: self.maxRealValue, maxSliderValue: self.maxSliderValue, isLogarithmic: self.isLogarithmic)
}
}
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let maskNode: ASImageNode
private var sliderView: TGPhotoEditorSliderView?
private let leftTextNode: ImmediateTextNode
private let rightTextNode: ImmediateTextNode
private let centerTextButtonNode: HighlightableButtonNode
private let centerTextButtonBackground: UIImageView
private let centerLeftTextNode: ImmediateTextNode
private let centerRightTextNode: ImmediateTextNode
private let lockIconNode: ASImageNode
private let button: ComponentView<Empty>
private var amount: Amount = Amount(realValue: 1, minRealValue: 1, maxRealValue: 1000, maxSliderValue: 1000, isLogarithmic: true)
private var item: MessagePriceItem?
private var layoutParams: ListViewItemLayoutParams?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.leftTextNode = ImmediateTextNode()
self.rightTextNode = ImmediateTextNode()
self.centerTextButtonNode = HighlightableButtonNode()
self.centerTextButtonBackground = UIImageView()
self.centerLeftTextNode = ImmediateTextNode()
self.centerLeftTextNode.isUserInteractionEnabled = false
self.centerLeftTextNode.displaysAsynchronously = false
self.centerRightTextNode = ImmediateTextNode()
self.centerRightTextNode.displaysAsynchronously = false
self.centerRightTextNode.isUserInteractionEnabled = false
self.lockIconNode = ASImageNode()
self.lockIconNode.displaysAsynchronously = false
self.button = ComponentView<Empty>()
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.leftTextNode)
self.addSubnode(self.rightTextNode)
self.addSubnode(self.centerTextButtonNode)
self.centerTextButtonNode.addSubnode(self.centerLeftTextNode)
self.centerTextButtonNode.addSubnode(self.centerRightTextNode)
self.addSubnode(self.lockIconNode)
self.centerTextButtonNode.addTarget(self, action: #selector(self.centerTextButtonPressed), forControlEvents: .touchUpInside)
}
override func didLoad() {
super.didLoad()
let sliderView = TGPhotoEditorSliderView()
sliderView.enableEdgeTap = true
sliderView.enablePanHandling = true
sliderView.trackCornerRadius = 1.0
sliderView.lineSize = 4.0
sliderView.disablesInteractiveTransitionGestureRecognizer = true
if let item = self.item, let params = self.layoutParams {
self.amount = Amount(realValue: Int(item.value), minRealValue: Int(item.minValue), maxRealValue: Int(item.maxValue), maxSliderValue: 999, isLogarithmic: true)
sliderView.minimumValue = 0
sliderView.startValue = 0
sliderView.maximumValue = CGFloat(self.amount.maxSliderValue)
sliderView.displayEdges = true
sliderView.value = CGFloat(self.amount.sliderValue)
sliderView.backgroundColor = item.theme.list.itemBlocksBackgroundColor
sliderView.backColor = item.theme.list.itemSwitchColors.frameColor
sliderView.trackColor = item.theme.list.itemAccentColor
sliderView.knobImage = PresentationResourcesItemList.knobImage(item.theme)
sliderView.frame = CGRect(origin: CGPoint(x: params.leftInset + 18.0, y: 36.0), size: CGSize(width: params.width - params.leftInset - params.rightInset - 18.0 * 2.0, height: 44.0))
}
self.view.addSubview(sliderView)
sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged)
self.sliderView = sliderView
self.centerTextButtonNode.view.insertSubview(self.centerTextButtonBackground, at: 0)
}
@objc private func centerTextButtonPressed() {
self.item?.openSetCustom?()
}
func asyncLayout() -> (_ item: MessagePriceItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let currentItem = self.item
return { item, params, neighbors in
var themeUpdated = false
if currentItem?.theme !== item.theme {
themeUpdated = true
}
var contentSize: CGSize
let insets: UIEdgeInsets
let separatorHeight = UIScreenPixel
let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0
contentSize = CGSize(width: params.width, height: 88.0)
if !item.isEnabled {
contentSize.height = 166.0
}
insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.maskNode, at: 3)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
let bottomStripeOffset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = params.leftInset + 16.0
bottomStripeOffset = -separatorHeight
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
bottomStripeOffset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight))
strongSelf.leftTextNode.attributedText = NSAttributedString(string: "\(item.minValue)", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor)
strongSelf.rightTextNode.attributedText = NSAttributedString(string: "\(item.maxValue)", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor)
let centralLeftText = item.value == 0 ? item.strings.Stars_SendMessage_PriceFree : item.strings.Privacy_Messages_Stars(Int32(clamping: item.value))
strongSelf.centerLeftTextNode.attributedText = NSAttributedString(string: centralLeftText, font: textFont, textColor: item.openSetCustom != nil && item.isEnabled ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor)
strongSelf.centerRightTextNode.attributedText = NSAttributedString(string: item.price, font: smallTextFont, textColor: item.openSetCustom != nil && item.isEnabled ? item.theme.list.itemAccentColor.withMultipliedAlpha(0.5) : item.theme.list.itemSecondaryTextColor)
let leftTextSize = strongSelf.leftTextNode.updateLayout(CGSize(width: 100.0, height: 100.0))
let rightTextSize = strongSelf.rightTextNode.updateLayout(CGSize(width: 100.0, height: 100.0))
let centerLeftTextSize = strongSelf.centerLeftTextNode.updateLayout(CGSize(width: 200.0, height: 100.0))
let centerRightTextSize = strongSelf.centerRightTextNode.updateLayout(CGSize(width: 200.0, height: 100.0))
let centerSpacing: CGFloat = item.price.isEmpty ? 0.0 : 6.0
let sideInset: CGFloat = 18.0
strongSelf.leftTextNode.frame = CGRect(origin: CGPoint(x: params.leftInset + sideInset, y: 15.0), size: leftTextSize)
strongSelf.rightTextNode.frame = CGRect(origin: CGPoint(x: params.width - params.leftInset - sideInset - rightTextSize.width, y: 15.0), size: rightTextSize)
let totalCenterWidth = centerLeftTextSize.width + centerSpacing + centerRightTextSize.width
let centerLeftFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.width - totalCenterWidth) / 2.0), y: 11.0), size: centerLeftTextSize)
let centerRightFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((params.width - totalCenterWidth) / 2.0) + totalCenterWidth - centerRightTextSize.width, y: 14.0 - UIScreenPixel), size: centerRightTextSize)
let centerButtonFrame = CGRect(origin: CGPoint(x: centerLeftFrame.minX, y: centerLeftFrame.minY), size: CGSize(width: centerRightFrame.maxX - centerLeftFrame.minX, height: centerLeftFrame.height)).insetBy(dx: -8.0, dy: -4.0)
strongSelf.centerTextButtonNode.frame = centerButtonFrame
strongSelf.centerTextButtonBackground.frame = CGRect(origin: CGPoint(x: 0.0, y: UIScreenPixel), size: centerButtonFrame.size)
if strongSelf.centerTextButtonBackground.image == nil {
strongSelf.centerTextButtonBackground.image = generateStretchableFilledCircleImage(diameter: 16.0, color: .white)?.withRenderingMode(.alwaysTemplate)
}
strongSelf.centerTextButtonBackground.tintColor = item.theme.list.itemAccentColor.withMultipliedAlpha(0.1)
if item.openSetCustom != nil && item.isEnabled {
strongSelf.centerTextButtonNode.isEnabled = true
strongSelf.centerTextButtonBackground.isHidden = false
} else {
strongSelf.centerTextButtonNode.isEnabled = false
strongSelf.centerTextButtonBackground.isHidden = true
}
strongSelf.centerLeftTextNode.frame = centerLeftFrame.offsetBy(dx: -centerButtonFrame.minX, dy: -centerButtonFrame.minY)
strongSelf.centerRightTextNode.frame = centerRightFrame.offsetBy(dx: -centerButtonFrame.minX, dy: -centerButtonFrame.minY)
if let sliderView = strongSelf.sliderView {
if themeUpdated {
sliderView.backgroundColor = item.theme.list.itemBlocksBackgroundColor
sliderView.backColor = item.theme.list.itemSecondaryTextColor
sliderView.trackColor = item.theme.list.itemAccentColor.withAlphaComponent(0.45)
sliderView.knobImage = PresentationResourcesItemList.knobImage(item.theme)
}
sliderView.frame = CGRect(origin: CGPoint(x: params.leftInset + 18.0, y: 36.0), size: CGSize(width: params.width - params.leftInset - params.rightInset - 18.0 * 2.0, height: 44.0))
sliderView.interactionEnded = {
guard let self else {
return
}
self.item?.updated(Int64(self.amount.realValue), true)
}
if !sliderView.isTracking {
strongSelf.amount = Amount(realValue: Int(item.value), minRealValue: Int(item.minValue), maxRealValue: Int(item.maxValue), maxSliderValue: 999, isLogarithmic: true)
sliderView.value = CGFloat(strongSelf.amount.sliderValue)
}
}
strongSelf.lockIconNode.isHidden = item.isEnabled
if !item.isEnabled {
if themeUpdated {
strongSelf.lockIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/SmallLock"), color: item.theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.5))
}
if let image = strongSelf.lockIconNode.image {
strongSelf.lockIconNode.frame = CGRect(origin: CGPoint(x: centerLeftFrame.minX - image.size.width - 1.0, y: 12.0 + UIScreenPixel), size: image.size)
}
let sideInset: CGFloat = 16.0
let buttonSize = CGSize(width: params.width - params.leftInset - params.rightInset - sideInset * 2.0, height: 52.0)
let _ = strongSelf.button.update(
transition: .immediate,
component: AnyComponent(
ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: item.theme.list.itemCheckColors.fillColor,
foreground: item.theme.list.itemCheckColors.foregroundColor,
pressedColor: item.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable("unlock"),
component: AnyComponent(
HStack([
AnyComponentWithIdentity(
id: AnyHashable("icon"),
component: AnyComponent(BundleIconComponent(name: "Chat/Stickers/Lock", tintColor: item.theme.list.itemCheckColors.foregroundColor))
),
AnyComponentWithIdentity(
id: AnyHashable("label"),
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: item.strings.Privacy_Messages_Unlock, font: Font.semibold(17.0), textColor: item.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center))))
)
], spacing: 3.0)
)
),
isEnabled: true,
tintWhenDisabled: false,
allowActionWhenDisabled: false,
displaysProgress: false,
action: { [weak self] in
guard let self, let item = self.item else {
return
}
item.openPremiumInfo?()
}
)
),
environment: {},
containerSize: buttonSize
)
if let buttonView = strongSelf.button.view {
if buttonView.superview == nil {
strongSelf.view.addSubview(buttonView)
}
buttonView.frame = CGRect(origin: CGPoint(x: params.leftInset + sideInset, y: contentSize.height - buttonSize.height - sideInset), size: buttonSize)
}
} else if let buttonView = strongSelf.button.view, buttonView.superview != nil {
buttonView.removeFromSuperview()
}
}
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
@objc func sliderValueChanged() {
guard let sliderView = self.sliderView else {
return
}
var updatedAmount = self.amount.withSliderValue(Int(sliderView.value))
if updatedAmount.realValue > 50 {
if updatedAmount.realValue < 100 {
updatedAmount = updatedAmount.withRealValue(Int(round(Double(updatedAmount.realValue) / 1.0) * 1.0))
} else if updatedAmount.realValue < 500 {
updatedAmount = updatedAmount.withRealValue(Int(round(Double(updatedAmount.realValue) / 5.0) * 5.0))
} else if updatedAmount.realValue < 1000 {
updatedAmount = updatedAmount.withRealValue(Int(round(Double(updatedAmount.realValue) / 10.0) * 10.0))
} else if updatedAmount.realValue < 5000 {
updatedAmount = updatedAmount.withRealValue(Int(round(Double(updatedAmount.realValue) / 25.0) * 25.0))
} else {
updatedAmount = updatedAmount.withRealValue(Int(round(Double(updatedAmount.realValue) / 50.0) * 50.0))
}
}
self.amount = updatedAmount
self.item?.updated(Int64(self.amount.realValue), false)
}
}
@@ -0,0 +1,31 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoChatListPaneNode",
module_name = "PeerInfoChatListPaneNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/TelegramStringFormatting",
"//submodules/ShimmerEffect",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/ChatListUI",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoPaneNode",
"//submodules/DeleteChatPeerActionSheetItem",
"//submodules/UndoUI",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,652 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import AccountContext
import ContextUI
import TelegramStringFormatting
import ShimmerEffect
import ComponentFlow
import TelegramNotices
import TelegramUIPreferences
import AppBundle
import PeerInfoPaneNode
import ChatListUI
import DeleteChatPeerActionSheetItem
import UndoUI
private final class SearchNavigationContentNode: ASDisplayNode, PeerInfoPanelNodeNavigationContentNode {
private struct Params: Equatable {
var width: CGFloat
var defaultHeight: CGFloat
var insets: UIEdgeInsets
init(width: CGFloat, defaultHeight: CGFloat, insets: UIEdgeInsets) {
self.width = width
self.defaultHeight = defaultHeight
self.insets = insets
}
}
weak var chatController: ChatController?
let contentNode: NavigationBarContentNode
var panelNode: ChatControllerCustomNavigationPanelNode?
private var appliedPanelNode: ChatControllerCustomNavigationPanelNode?
private var params: Params?
init(chatController: ChatController, contentNode: NavigationBarContentNode) {
self.chatController = chatController
self.contentNode = contentNode
super.init()
self.addSubnode(self.contentNode)
}
func update(transition: ContainedViewLayoutTransition) {
if let params = self.params {
let _ = self.update(width: params.width, defaultHeight: params.defaultHeight, insets: params.insets, transition: transition)
}
}
func update(width: CGFloat, defaultHeight: CGFloat, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) -> CGFloat {
self.params = Params(width: width, defaultHeight: defaultHeight, insets: insets)
let size = CGSize(width: width, height: defaultHeight)
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 10.0), size: size))
self.contentNode.updateLayout(size: size, leftInset: insets.left, rightInset: insets.right, transition: transition)
var contentHeight: CGFloat = size.height + 10.0
if self.appliedPanelNode !== self.panelNode {
if let previous = self.appliedPanelNode {
transition.updateAlpha(node: previous, alpha: 0.0, completion: { [weak previous] _ in
previous?.removeFromSupernode()
})
}
self.appliedPanelNode = self.panelNode
if let panelNode = self.panelNode, let chatController = self.chatController {
self.addSubnode(panelNode)
let panelLayout = panelNode.updateLayout(width: width, leftInset: insets.left, rightInset: insets.right, transition: .immediate, chatController: chatController)
let panelHeight = panelLayout.backgroundHeight
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: panelHeight))
panelNode.frame = panelFrame
panelNode.alpha = 0.0
transition.updateAlpha(node: panelNode, alpha: 1.0)
contentHeight += panelHeight - 1.0
}
} else if let panelNode = self.panelNode, let chatController = self.chatController {
let panelLayout = panelNode.updateLayout(width: width, leftInset: insets.left, rightInset: insets.right, transition: transition, chatController: chatController)
let panelHeight = panelLayout.backgroundHeight
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: panelHeight))
transition.updateFrame(node: panelNode, frame: panelFrame)
contentHeight += panelHeight - 1.0
}
return contentHeight
}
}
public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
private let context: AccountContext
private let navigationController: () -> NavigationController?
public weak var parentController: ViewController?
private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)?
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
public var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
public var status: Signal<PeerInfoStatusData?, NoError> {
self.statusPromise.get()
}
public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
public var tabBarOffset: CGFloat {
return 0.0
}
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let chatListNode: ChatListNode
private var emptyShimmerEffectNode: ChatListShimmerNode?
private var shimmerNodeOffset: CGFloat = 0.0
private var floatingHeaderOffset: CGFloat?
private let coveringView: UIView
private var chatController: ChatController?
private var removeChatWhenNotSearching: Bool = false
private var searchNavigationContentNode: SearchNavigationContentNode?
public var navigationContentNode: PeerInfoPanelNodeNavigationContentNode? {
return self.searchNavigationContentNode
}
public var externalDataUpdated: ((ContainedViewLayoutTransition) -> Void)?
public init(context: AccountContext, navigationController: @escaping () -> NavigationController?) {
self.context = context
self.navigationController = navigationController
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
let strings = presentationData.strings
self.coveringView = UIView()
self.chatListNode = ChatListNode(
context: self.context,
location: .savedMessagesChats(peerId: context.account.peerId),
chatListFilter: nil,
previewing: false,
fillPreloadItems: false,
mode: .chatList(appendContacts: false),
isPeerEnabled: nil,
theme: self.presentationData.theme,
fontSize: self.presentationData.listsFontSize,
strings: self.presentationData.strings,
dateTimeFormat: self.presentationData.dateTimeFormat,
nameSortOrder: self.presentationData.nameSortOrder,
nameDisplayOrder: self.presentationData.nameDisplayOrder,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
disableAnimations: false,
isInlineMode: false,
autoSetReady: false,
isMainTab: nil
)
self.chatListNode.synchronousDrawingWhenNotAnimated = true
super.init()
self.clipsToBounds = true
self.addSubnode(self.chatListNode)
self.view.addSubview(self.coveringView)
self.presentationDataDisposable = (self.context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
guard let self else {
return
}
self.presentationData = presentationData
})
self.ready.set(self.chatListNode.ready)
self.statusPromise.set(self.context.engine.messages.savedMessagesPeersStats()
|> map { count in
if let count {
return PeerInfoStatusData(text: strings.Notifications_Exceptions(Int32(count)), isActivity: false, key: .savedMessagesChats)
} else {
return PeerInfoStatusData(text: strings.Channel_NotificationLoading.lowercased(), isActivity: false, key: .savedMessagesChats)
}
})
self.chatListNode.peerSelected = { [weak self] peer, _, _, _, _ in
guard let self, let navigationController = self.navigationController() else {
return
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: self.context,
chatLocation: .replyThread(ChatReplyThreadMessage(
peerId: self.context.account.peerId,
threadId: peer.id.toInt64(),
channelMessageId: nil,
isChannelPost: false,
isForumPost: false,
isMonoforumPost: false,
maxMessage: nil,
maxReadIncomingMessageId: nil,
maxReadOutgoingMessageId: nil,
unreadCount: 0,
initialFilledHoles: IndexSet(),
initialAnchor: .automatic,
isNotAvailable: false
)),
subject: nil,
keepStack: .always
))
self.chatListNode.clearHighlightAnimated(true)
}
self.chatListNode.isEmptyUpdated = { [weak self] isEmptyState, _, transition in
guard let self else {
return
}
var needsShimmerNode = false
let shimmerNodeOffset: CGFloat = 0.0
switch isEmptyState {
case let .empty(isLoadingValue, _):
if isLoadingValue {
needsShimmerNode = true
}
case .notEmpty:
break
}
if needsShimmerNode {
self.shimmerNodeOffset = shimmerNodeOffset
if self.emptyShimmerEffectNode == nil {
let emptyShimmerEffectNode = ChatListShimmerNode()
self.emptyShimmerEffectNode = emptyShimmerEffectNode
self.insertSubnode(emptyShimmerEffectNode, belowSubnode: self.chatListNode)
if let currentParams = self.currentParams, let offset = self.floatingHeaderOffset {
self.layoutEmptyShimmerEffectNode(node: emptyShimmerEffectNode, size: currentParams.size, insets: UIEdgeInsets(top: currentParams.topInset, left: currentParams.sideInset, bottom: currentParams.bottomInset, right: currentParams.sideInset), verticalOffset: offset + self.shimmerNodeOffset, transition: .immediate)
}
}
} else if let emptyShimmerEffectNode = self.emptyShimmerEffectNode {
self.emptyShimmerEffectNode = nil
let emptyNodeTransition = transition.isAnimated ? transition : .animated(duration: 0.3, curve: .easeInOut)
emptyNodeTransition.updateAlpha(node: emptyShimmerEffectNode, alpha: 0.0, completion: { [weak emptyShimmerEffectNode] _ in
emptyShimmerEffectNode?.removeFromSupernode()
})
self.chatListNode.alpha = 0.0
emptyNodeTransition.updateAlpha(node: self.chatListNode, alpha: 1.0)
}
}
self.chatListNode.updateFloatingHeaderOffset = { [weak self] offset, transition in
guard let self else {
return
}
self.floatingHeaderOffset = offset
if let currentParams = self.currentParams, let emptyShimmerEffectNode = self.emptyShimmerEffectNode {
self.layoutEmptyShimmerEffectNode(node: emptyShimmerEffectNode, size: currentParams.size, insets: UIEdgeInsets(top: currentParams.topInset, left: currentParams.sideInset, bottom: currentParams.bottomInset, right: currentParams.sideInset), verticalOffset: offset + self.shimmerNodeOffset, transition: transition)
}
}
self.chatListNode.push = { [weak self] c in
guard let self else {
return
}
self.parentController?.push(c)
}
self.chatListNode.present = { [weak self] c in
guard let self else {
return
}
self.parentController?.present(c, in: .window(.root))
}
self.chatListNode.deletePeerChat = { [weak self] peerId, _ in
guard let self else {
return
}
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer else {
return
}
self.view.window?.endEditing(true)
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
items.append(DeleteChatPeerActionSheetItem(context: self.context, peer: peer, chatPeer: peer, action: .deleteSavedPeer, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, balancedLayout: true))
items.append(ActionSheetButtonItem(title: self.presentationData.strings.Common_Delete, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self else {
return
}
self.chatListNode.updateState({ state in
var state = state
state.pendingRemovalItemIds.insert(ChatListNodeState.ItemId(peerId: peer.id, threadId: nil))
return state
})
self.parentController?.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitActionAndReplacementAnimation()
}
return true
})
if self.chatListNode.entryPeerIds.count == 0 || self.chatListNode.entryPeerIds == [peer.id] {
let _ = context.engine.messages.clearHistoryInteractively(peerId: self.context.account.peerId, threadId: peer.id.toInt64(), type: .forLocalPeer).startStandalone(completed: {
})
context.engine.peers.updateSavedMessagesViewAsTopics(value: false)
self.parentController?.dismiss()
return
}
let context = self.context
let undoController = UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: self.presentationData.strings.SavedMessages_SubChatDeleted), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
if value == .commit {
let _ = context.engine.messages.clearHistoryInteractively(peerId: context.account.peerId, threadId: peer.id.toInt64(), type: .forLocalPeer).startStandalone(completed: {
guard let self else {
return
}
self.chatListNode.updateState({ state in
var state = state
state.pendingRemovalItemIds.remove(ChatListNodeState.ItemId(peerId: peer.id, threadId: nil))
return state
})
})
return true
} else if value == .undo {
if let self {
self.chatListNode.updateState({ state in
var state = state
state.pendingRemovalItemIds.remove(ChatListNodeState.ItemId(peerId: peer.id, threadId: nil))
return state
})
}
return true
}
return false
})
self.parentController?.present(undoController, in: .window(.root))
}))
actionSheet.setItemGroups([ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
self.parentController?.present(actionSheet, in: .window(.root))
})
}
self.chatListNode.activateChatPreview = { [weak self] item, _, node, gesture, location in
guard let self, let parentController = self.parentController else {
gesture?.cancel()
return
}
if case let .peer(peerData) = item.content {
let threadId = peerData.peer.peerId.toInt64()
let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .replyThread(message: ChatReplyThreadMessage(
peerId: self.context.account.peerId, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: false, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false
)), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil)
chatController.canReadHistory.set(false)
let source: ContextContentSource = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: parentController.navigationController as? NavigationController))
let contextController = ContextController(presentationData: self.presentationData, source: source, items: savedMessagesPeerMenuItems(context: self.context, threadId: threadId, parentController: parentController, deletePeerChat: { [weak self] peerId in
guard let self else {
return
}
self.chatListNode.deletePeerChat?(peerId, false)
}) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
parentController.presentInGlobalOverlay(contextController)
}
}
}
deinit {
self.presentationDataDisposable?.dispose()
}
public func activateSearch() {
if self.chatController == nil {
let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .peer(id: self.context.account.peerId), subject: nil, botStart: nil, mode: .standard(.embedded(invertDirection: false)), params: nil)
chatController.alwaysShowSearchResultsAsList = true
chatController.includeSavedPeersInSearchResults = true
self.chatController = chatController
chatController.navigation_setNavigationController(self.navigationController())
self.insertSubnode(chatController.displayNode, aboveSubnode: self.chatListNode)
chatController.displayNode.alpha = 0.0
chatController.displayNode.clipsToBounds = true
self.updateChatController(transition: .immediate)
let _ = (chatController.ready.get()
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self, weak chatController] _ in
guard let self, let chatController, self.chatController === chatController else {
return
}
chatController.customDismissSearch = { [weak self] in
guard let self else {
return
}
if self.searchNavigationContentNode !== nil {
self.searchNavigationContentNode = nil
self.externalDataUpdated?(.animated(duration: 0.4, curve: .spring))
}
self.removeChatController()
}
chatController.stateUpdated = { [weak self] transition in
guard let self, let chatController = self.chatController else {
return
}
if let contentNode = chatController.customNavigationBarContentNode {
self.removeChatWhenNotSearching = true
chatController.displayNode.layer.allowsGroupOpacity = true
if transition.isAnimated {
ComponentTransition.easeInOut(duration: 0.2).setAlpha(layer: chatController.displayNode.layer, alpha: 1.0)
}
if self.searchNavigationContentNode?.contentNode !== contentNode {
self.searchNavigationContentNode = SearchNavigationContentNode(chatController: chatController, contentNode: contentNode)
self.searchNavigationContentNode?.panelNode = chatController.customNavigationPanelNode
self.externalDataUpdated?(transition)
} else if self.searchNavigationContentNode?.panelNode !== chatController.customNavigationPanelNode {
self.searchNavigationContentNode?.panelNode = chatController.customNavigationPanelNode
self.externalDataUpdated?(transition.isAnimated ? transition : .animated(duration: 0.4, curve: .spring))
} else {
self.searchNavigationContentNode?.update(transition: transition)
}
} else {
if self.searchNavigationContentNode !== nil {
self.searchNavigationContentNode = nil
self.externalDataUpdated?(transition)
}
if self.removeChatWhenNotSearching {
self.removeChatController()
}
}
}
chatController.activateSearch(domain: .everything, query: "")
})
}
}
private func removeChatController() {
if let chatController = self.chatController {
self.chatController = nil
let displayNode = chatController.displayNode
chatController.displayNode.layer.allowsGroupOpacity = true
chatController.displayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak displayNode] _ in
displayNode?.removeFromSupernode()
})
}
}
public func ensureMessageIsVisible(id: MessageId) {
}
public func scrollToTop() -> Bool {
if let chatController = self.chatController {
let _ = chatController.performScrollToTop()
} else {
self.chatListNode.scrollToPosition(.top(adjustForTempInset: false))
}
return false
}
public func hitTestResultForScrolling() -> UIView? {
return nil
}
public func brieflyDisableTouchActions() {
}
public func findLoadedMessage(id: MessageId) -> Message? {
return nil
}
public func updateHiddenMedia() {
}
public func transferVelocity(_ velocity: CGFloat) {
}
public func cancelPreviewGestures() {
}
public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
public func addToTransitionSurface(view: UIView) {
}
override public func didLoad() {
super.didLoad()
}
override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
private func layoutEmptyShimmerEffectNode(node: ChatListShimmerNode, size: CGSize, insets: UIEdgeInsets, verticalOffset: CGFloat, transition: ContainedViewLayoutTransition) {
node.update(context: self.context, animationCache: self.context.animationCache, animationRenderer: self.context.animationRenderer, size: size, isInlineMode: false, presentationData: self.presentationData, transition: .immediate)
transition.updateFrameAdditive(node: node, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: size))
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer.state != .failed, let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer {
let _ = otherGestureRecognizer
return true
} else {
return false
}
}
public func updateSelectedMessages(animated: Bool) {
}
private func updateChatController(transition: ContainedViewLayoutTransition) {
guard let chatController = self.chatController else {
return
}
guard let currentParams = self.currentParams else {
return
}
let size = currentParams.size
let topInset = currentParams.topInset
let sideInset = currentParams.sideInset
let bottomInset = currentParams.bottomInset
let navigationHeight = currentParams.navigationHeight
let deviceMetrics = currentParams.deviceMetrics
let isScrollingLockedAtTop = currentParams.isScrollingLockedAtTop
let fullHeight = navigationHeight + size.height
let chatFrame = CGRect(origin: CGPoint(x: 0.0, y: -navigationHeight), size: CGSize(width: size.width, height: fullHeight))
if !chatController.displayNode.bounds.isEmpty {
if let contextController = chatController.visibleContextController as? ContextController {
let deltaY = chatFrame.minY - chatController.displayNode.frame.minY
contextController.addRelativeContentOffset(CGPoint(x: 0.0, y: -deltaY * 0.0), transition: transition)
}
}
let combinedBottomInset = bottomInset
transition.updateFrame(node: chatController.displayNode, frame: chatFrame)
chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop)
chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition)
}
public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
self.currentParams = (size, topInset, sideInset, bottomInset, deviceMetrics: deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData)
self.coveringView.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
transition.updateFrame(view: self.coveringView, frame: CGRect(origin: CGPoint(x: 0.0, y: -1.0), size: CGSize(width: size.width, height: topInset + 1.0)))
let fullHeight = navigationHeight + size.height
let chatFrame = CGRect(origin: CGPoint(x: 0.0, y: -navigationHeight), size: CGSize(width: size.width, height: fullHeight))
let combinedBottomInset = bottomInset
transition.updateFrame(node: self.chatListNode, frame: chatFrame)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
self.chatListNode.updateLayout(
transition: transition,
updateSizeAndInsets: ListViewUpdateSizeAndInsets(
size: size,
insets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset),
duration: duration,
curve: curve
),
visibleTopInset: topInset + navigationHeight,
originalTopInset: topInset + navigationHeight,
storiesInset: 0.0,
inlineNavigationLocation: nil,
inlineNavigationTransitionFraction: 0.0
)
self.updateChatController(transition: transition)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
}
private final class ContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
let navigationController: NavigationController?
let passthroughTouches: Bool = true
init(controller: ViewController, sourceNode: ASDisplayNode?, navigationController: NavigationController?) {
self.controller = controller
self.sourceNode = sourceNode
self.navigationController = navigationController
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceNode = self.sourceNode
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceNode = sourceNode {
return (sourceNode.view, sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}
@@ -0,0 +1,28 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoChatPaneNode",
module_name = "PeerInfoChatPaneNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/TelegramStringFormatting",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoPaneNode",
"//submodules/ContextUI",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,305 @@
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import AccountContext
import TelegramStringFormatting
import ComponentFlow
import TelegramUIPreferences
import AppBundle
import PeerInfoPaneNode
import ContextUI
private final class SearchNavigationContentNode: ASDisplayNode, PeerInfoPanelNodeNavigationContentNode {
private struct Params: Equatable {
var width: CGFloat
var defaultHeight: CGFloat
var insets: UIEdgeInsets
init(width: CGFloat, defaultHeight: CGFloat, insets: UIEdgeInsets) {
self.width = width
self.defaultHeight = defaultHeight
self.insets = insets
}
}
weak var chatController: ChatController?
let contentNode: NavigationBarContentNode
var panelNode: ChatControllerCustomNavigationPanelNode?
private var appliedPanelNode: ChatControllerCustomNavigationPanelNode?
private var params: Params?
init(chatController: ChatController, contentNode: NavigationBarContentNode) {
self.chatController = chatController
self.contentNode = contentNode
super.init()
self.addSubnode(self.contentNode)
}
func update(transition: ContainedViewLayoutTransition) {
if let params = self.params {
let _ = self.update(width: params.width, defaultHeight: params.defaultHeight, insets: params.insets, transition: transition)
}
}
func update(width: CGFloat, defaultHeight: CGFloat, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) -> CGFloat {
self.params = Params(width: width, defaultHeight: defaultHeight, insets: insets)
let size = CGSize(width: width, height: defaultHeight)
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 10.0), size: size))
self.contentNode.updateLayout(size: size, leftInset: insets.left, rightInset: insets.right, transition: transition)
var contentHeight: CGFloat = size.height + 10.0
if self.appliedPanelNode !== self.panelNode {
if let previous = self.appliedPanelNode {
transition.updateAlpha(node: previous, alpha: 0.0, completion: { [weak previous] _ in
previous?.removeFromSupernode()
})
}
self.appliedPanelNode = self.panelNode
if let panelNode = self.panelNode, let chatController = self.chatController {
self.addSubnode(panelNode)
let panelLayout = panelNode.updateLayout(width: width, leftInset: insets.left, rightInset: insets.right, transition: .immediate, chatController: chatController)
let panelHeight = panelLayout.backgroundHeight
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: panelHeight))
panelNode.frame = panelFrame
panelNode.alpha = 0.0
transition.updateAlpha(node: panelNode, alpha: 1.0)
contentHeight += panelHeight - 1.0
}
} else if let panelNode = self.panelNode, let chatController = self.chatController {
let panelLayout = panelNode.updateLayout(width: width, leftInset: insets.left, rightInset: insets.right, transition: transition, chatController: chatController)
let panelHeight = panelLayout.backgroundHeight
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: panelHeight))
transition.updateFrame(node: panelNode, frame: panelFrame)
contentHeight += panelHeight - 1.0
}
return contentHeight
}
}
public final class PeerInfoChatPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
private let context: AccountContext
private let peerId: EnginePeer.Id
private let navigationController: () -> NavigationController?
private let chatController: ChatController
private let coveringView: UIView
public weak var parentController: ViewController? {
didSet {
if self.parentController !== oldValue {
if let parentController = self.parentController {
self.chatController.willMove(toParent: parentController)
parentController.addChild(self.chatController)
self.chatController.didMove(toParent: parentController)
} else {
self.chatController.willMove(toParent: nil)
self.chatController.removeFromParent()
self.chatController.didMove(toParent: nil)
}
}
}
}
private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)?
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
public var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
public var status: Signal<PeerInfoStatusData?, NoError> {
self.statusPromise.get()
}
public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
public var tabBarOffset: CGFloat {
return 0.0
}
private var searchNavigationContentNode: SearchNavigationContentNode?
public var navigationContentNode: PeerInfoPanelNodeNavigationContentNode? {
return self.searchNavigationContentNode
}
public var externalDataUpdated: ((ContainedViewLayoutTransition) -> Void)?
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
public init(context: AccountContext, peerId: EnginePeer.Id, navigationController: @escaping () -> NavigationController?) {
self.context = context
self.peerId = peerId
self.navigationController = navigationController
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.coveringView = UIView()
self.chatController = context.sharedContext.makeChatController(context: context, chatLocation: .replyThread(message: ChatReplyThreadMessage(peerId: context.account.peerId, threadId: peerId.toInt64(), channelMessageId: nil, isChannelPost: false, isForumPost: false, isMonoforumPost: false, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)), subject: nil, botStart: nil, mode: .standard(.embedded(invertDirection: true)), params: nil)
self.chatController.navigation_setNavigationController(navigationController())
super.init()
self.clipsToBounds = true
self.presentationDataDisposable = (self.context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
guard let self else {
return
}
self.presentationData = presentationData
})
let strings = self.presentationData.strings
self.statusPromise.set(self.context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Messages.MessageCount(peerId: self.context.account.peerId, threadId: peerId.toInt64(), tag: [])
)
|> map { count in
if let count {
return PeerInfoStatusData(text: strings.Conversation_Messages(Int32(count)), isActivity: false, key: .savedMessages)
} else {
return nil
}
})
self.ready.set(self.chatController.ready.get())
self.addSubnode(self.chatController.displayNode)
self.chatController.displayNode.clipsToBounds = true
self.view.addSubview(self.coveringView)
self.chatController.stateUpdated = { [weak self] transition in
guard let self else {
return
}
if let contentNode = self.chatController.customNavigationBarContentNode {
if self.searchNavigationContentNode?.contentNode !== contentNode {
self.searchNavigationContentNode = SearchNavigationContentNode(chatController: self.chatController, contentNode: contentNode)
self.searchNavigationContentNode?.panelNode = self.chatController.customNavigationPanelNode
self.externalDataUpdated?(transition)
} else if self.searchNavigationContentNode?.panelNode !== self.chatController.customNavigationPanelNode {
self.searchNavigationContentNode?.panelNode = self.chatController.customNavigationPanelNode
self.externalDataUpdated?(transition.isAnimated ? transition : .animated(duration: 0.4, curve: .spring))
} else {
self.searchNavigationContentNode?.update(transition: transition)
}
} else {
if self.searchNavigationContentNode !== nil {
self.searchNavigationContentNode = nil
self.externalDataUpdated?(transition)
}
}
}
}
deinit {
self.presentationDataDisposable?.dispose()
}
public func ensureMessageIsVisible(id: MessageId) {
}
public func scrollToTop() -> Bool {
return self.chatController.performScrollToTop()
}
public func hitTestResultForScrolling() -> UIView? {
return nil
}
public func brieflyDisableTouchActions() {
}
public func findLoadedMessage(id: MessageId) -> Message? {
return nil
}
public func updateHiddenMedia() {
}
public func transferVelocity(_ velocity: CGFloat) {
if velocity > 0.0 {
self.chatController.transferScrollingVelocity(velocity)
}
}
public func cancelPreviewGestures() {
}
public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
public func addToTransitionSurface(view: UIView) {
}
override public func didLoad() {
super.didLoad()
}
public func activateSearch() {
self.chatController.activateSearch(domain: .everything, query: "")
}
override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer.state != .failed, let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer {
let _ = otherGestureRecognizer
return true
} else {
return false
}
}
public func updateSelectedMessages(animated: Bool) {
}
public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
self.currentParams = (size, topInset, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData)
let fullHeight = navigationHeight + size.height
let chatFrame = CGRect(origin: CGPoint(x: 0.0, y: -navigationHeight), size: CGSize(width: size.width, height: fullHeight))
if !self.chatController.displayNode.bounds.isEmpty {
if let contextController = self.chatController.visibleContextController as? ContextController {
let deltaY = chatFrame.minY - self.chatController.displayNode.frame.minY
contextController.addRelativeContentOffset(CGPoint(x: 0.0, y: -deltaY * 0.0), transition: transition)
}
}
self.coveringView.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
transition.updateFrame(view: self.coveringView, frame: CGRect(origin: CGPoint(x: 0.0, y: -1.0), size: CGSize(width: size.width, height: topInset + 1.0)))
let combinedBottomInset = bottomInset
transition.updateFrame(node: self.chatController.displayNode, frame: chatFrame)
self.chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop)
self.chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
}
@@ -0,0 +1,31 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoCoverComponent",
module_name = "PeerInfoCoverComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Display",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/MultiAnimationRenderer",
"//submodules/TelegramUI/Components/AnimationCache",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/Utils/LokiRng",
"//submodules/TextFormat",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,609 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import AnimationCache
import MultiAnimationRenderer
import TelegramCore
import AccountContext
import SwiftSignalKit
import EmojiTextAttachmentView
import LokiRng
private final class PatternContentsTarget: MultiAnimationRenderTarget {
private let imageUpdated: (Bool) -> Void
init(imageUpdated: @escaping (Bool) -> Void) {
self.imageUpdated = imageUpdated
super.init()
}
required init(coder: NSCoder) {
preconditionFailure()
}
override func transitionToContents(_ contents: AnyObject, didLoop: Bool) {
let hadContents = self.contents != nil
self.contents = contents
self.imageUpdated(hadContents)
}
}
private func windowFunction(t: CGFloat) -> CGFloat {
return bezierPoint(0.6, 0.0, 0.4, 1.0, t)
}
private func patternScaleValueAt(fraction: CGFloat, t: CGFloat, reverse: Bool) -> CGFloat {
let windowSize: CGFloat = 0.8
let effectiveT: CGFloat
let windowStartOffset: CGFloat
let windowEndOffset: CGFloat
if reverse {
effectiveT = 1.0 - t
windowStartOffset = 1.0
windowEndOffset = -windowSize
} else {
effectiveT = t
windowStartOffset = -windowSize
windowEndOffset = 1.0
}
let windowPosition = (1.0 - fraction) * windowStartOffset + fraction * windowEndOffset
let windowT = max(0.0, min(windowSize, effectiveT - windowPosition)) / windowSize
let localT = 1.0 - windowFunction(t: windowT)
return localT
}
public final class PeerInfoCoverComponent: Component {
public enum Subject: Equatable {
case peer(EnginePeer)
case status(PeerEmojiStatus)
case custom(UIColor?, UIColor?, UIColor?, Int64?)
func colors(context: AccountContext, isDark: Bool) -> (UIColor, UIColor)? {
switch self {
case let .peer(peer):
if let colors = peer._asPeer().profileColor.flatMap({ context.peerNameColors.getProfile($0, dark: isDark) }) {
let backgroundColor = colors.main
let secondaryBackgroundColor = colors.secondary ?? colors.main
return (backgroundColor, secondaryBackgroundColor)
} else {
return nil
}
case let .status(status):
if case let .starGift(_, _, _, _, _, innerColor, outerColor, _, _) = status.content {
return (UIColor(rgb: UInt32(bitPattern: innerColor)), UIColor(rgb: UInt32(bitPattern: outerColor)))
}
return nil
case let .custom(color, secondColor, _, _):
if let color {
if let secondColor {
return (color, secondColor)
} else {
return (color, color)
}
} else {
return nil
}
}
}
var fileId: Int64? {
switch self {
case let .peer(peer):
return peer.profileBackgroundEmojiId
case let .status(status):
if case let .starGift(_, _, _, _, patternFileId, _, _, _, _) = status.content {
return patternFileId
}
return nil
case let .custom(_, _, _, fileId):
return fileId
}
}
}
public let context: AccountContext
public let subject: Subject?
public let files: [Int64: TelegramMediaFile]
public let isDark: Bool
public let avatarCenter: CGPoint
public let avatarSize: CGSize
public let avatarScale: CGFloat
public let defaultHeight: CGFloat
public let gradientOnTop: Bool
public let gradientCenter: CGPoint
public let avatarTransitionFraction: CGFloat
public let patternTransitionFraction: CGFloat
public init(
context: AccountContext,
subject: Subject?,
files: [Int64: TelegramMediaFile],
isDark: Bool,
avatarCenter: CGPoint,
avatarSize: CGSize = CGSize(width: 100.0, height: 100.0),
avatarScale: CGFloat,
defaultHeight: CGFloat,
gradientOnTop: Bool = false,
gradientCenter: CGPoint = CGPoint(x: 0.5, y: 0.5),
avatarTransitionFraction: CGFloat,
patternTransitionFraction: CGFloat
) {
self.context = context
self.subject = subject
self.files = files
self.isDark = isDark
self.avatarCenter = avatarCenter
self.avatarSize = avatarSize
self.avatarScale = avatarScale
self.defaultHeight = defaultHeight
self.gradientOnTop = gradientOnTop
self.gradientCenter = gradientCenter
self.avatarTransitionFraction = avatarTransitionFraction
self.patternTransitionFraction = patternTransitionFraction
}
public static func ==(lhs: PeerInfoCoverComponent, rhs: PeerInfoCoverComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.subject != rhs.subject {
return false
}
if lhs.files != rhs.files {
return false
}
if lhs.isDark != rhs.isDark {
return false
}
if lhs.avatarCenter != rhs.avatarCenter {
return false
}
if lhs.avatarSize != rhs.avatarSize {
return false
}
if lhs.avatarScale != rhs.avatarScale {
return false
}
if lhs.defaultHeight != rhs.defaultHeight {
return false
}
if lhs.gradientOnTop != rhs.gradientOnTop {
return false
}
if lhs.gradientCenter != rhs.gradientCenter {
return false
}
if lhs.avatarTransitionFraction != rhs.avatarTransitionFraction {
return false
}
if lhs.patternTransitionFraction != rhs.patternTransitionFraction {
return false
}
return true
}
public final class View: UIView {
private let backgroundView: UIView
private let backgroundGradientLayer: SimpleGradientLayer
private let avatarBackgroundPatternContentsLayer: SimpleGradientLayer
private let avatarBackgroundPatternMaskLayer: SimpleLayer
private let avatarBackgroundGradientLayer: SimpleGradientLayer
private let backgroundPatternContainer: UIView
private var currentSize: CGSize?
private var component: PeerInfoCoverComponent?
private var state: EmptyComponentState?
private var patternContentsTarget: PatternContentsTarget?
private var avatarPatternContentLayers: [SimpleLayer] = []
private var patternFile: TelegramMediaFile?
private var patternFileDisposable: Disposable?
private var patternImageDisposable: Disposable?
override public init(frame: CGRect) {
self.backgroundView = UIView()
self.backgroundGradientLayer = SimpleGradientLayer()
self.avatarBackgroundGradientLayer = SimpleGradientLayer()
self.avatarBackgroundGradientLayer.opacity = 0.0
let baseAvatarGradientAlpha: CGFloat = 0.4
let numSteps = 6
self.avatarBackgroundGradientLayer.colors = (0 ..< numSteps).map { i in
let step: CGFloat = 1.0 - CGFloat(i) / CGFloat(numSteps - 1)
return UIColor(white: 1.0, alpha: baseAvatarGradientAlpha * pow(step, 2.0)).cgColor
}
self.avatarBackgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.avatarBackgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.avatarBackgroundGradientLayer.type = .radial
self.avatarBackgroundPatternContentsLayer = SimpleGradientLayer()
self.avatarBackgroundPatternContentsLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.avatarBackgroundPatternContentsLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.avatarBackgroundPatternContentsLayer.type = .radial
self.avatarBackgroundPatternMaskLayer = SimpleLayer()
self.backgroundPatternContainer = UIView()
super.init(frame: frame)
self.clipsToBounds = true
self.addSubview(self.backgroundView)
self.layer.addSublayer(self.backgroundGradientLayer)
self.layer.addSublayer(self.avatarBackgroundGradientLayer)
self.avatarBackgroundPatternContentsLayer.mask = self.avatarBackgroundPatternMaskLayer
self.layer.addSublayer(self.avatarBackgroundPatternContentsLayer)
self.addSubview(self.backgroundPatternContainer)
self.layer.allowsGroupOpacity = true
self.backgroundView.layer.allowsGroupOpacity = true
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.patternFileDisposable?.dispose()
self.patternImageDisposable?.dispose()
}
public func willAnimateIn() {
for layer in self.avatarPatternContentLayers {
layer.opacity = 0.0
}
}
public func animateIn() {
guard let _ = self.currentSize, let component = self.component else {
return
}
for layer in self.avatarPatternContentLayers {
layer.opacity = 1.0
layer.animatePosition(
from: component.avatarCenter,
to: layer.position,
duration: 0.4,
timingFunction: kCAMediaTimingFunctionSpring
)
}
}
public func animateSwipeTransition() {
if let gradientSnapshotLayer = self.backgroundGradientLayer.snapshotContentTree() {
let backgroundSnapshotLayer = SimpleLayer()
backgroundSnapshotLayer.masksToBounds = true
backgroundSnapshotLayer.allowsGroupOpacity = true
backgroundSnapshotLayer.backgroundColor = self.backgroundView.backgroundColor?.cgColor
backgroundSnapshotLayer.frame = self.backgroundView.frame
self.layer.insertSublayer(backgroundSnapshotLayer, above: self.backgroundGradientLayer)
gradientSnapshotLayer.frame = self.backgroundGradientLayer.convert(self.backgroundGradientLayer.bounds, to: self.backgroundView.layer)
backgroundSnapshotLayer.addSublayer(gradientSnapshotLayer)
let mask = CAGradientLayer()
mask.startPoint = CGPoint(x: 0.0, y: 0.5)
mask.endPoint = CGPoint(x: 1.0, y: 0.5)
mask.frame = CGRect(origin: CGPoint(x: backgroundSnapshotLayer.bounds.width, y: 0.0), size: CGSize(width: backgroundSnapshotLayer.bounds.width * 2.0, height: backgroundSnapshotLayer.bounds.height))
mask.colors = [
UIColor.white.withAlphaComponent(0.0).cgColor,
UIColor.white.cgColor,
UIColor.white.cgColor,
]
mask.locations = [0.0, 0.5, 1.0]
backgroundSnapshotLayer.mask = mask
mask.animatePosition(from: CGPoint(x: -backgroundSnapshotLayer.bounds.width * 2.0, y: 0.0), to: .zero, duration: 0.35, timingFunction: CAMediaTimingFunctionName.linear.rawValue, additive: true, completion: { [weak backgroundSnapshotLayer] _ in
backgroundSnapshotLayer?.removeFromSuperlayer()
})
}
}
public func animateTransition(background: Bool = true, bounce: Bool = true) {
if background, let gradientSnapshotLayer = self.backgroundGradientLayer.snapshotContentTree() {
let backgroundSnapshotLayer = SimpleLayer()
backgroundSnapshotLayer.allowsGroupOpacity = true
backgroundSnapshotLayer.backgroundColor = self.backgroundView.backgroundColor?.cgColor
backgroundSnapshotLayer.frame = self.backgroundView.frame
self.layer.insertSublayer(backgroundSnapshotLayer, above: self.backgroundGradientLayer)
backgroundSnapshotLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
backgroundSnapshotLayer.removeFromSuperlayer()
})
gradientSnapshotLayer.frame = self.backgroundGradientLayer.convert(self.backgroundGradientLayer.bounds, to: self.backgroundView.layer)
backgroundSnapshotLayer.addSublayer(gradientSnapshotLayer)
}
for layer in self.avatarPatternContentLayers {
if let _ = layer.contents, let snapshot = layer.snapshotContentTree() {
layer.superlayer?.addSublayer(snapshot)
snapshot.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
snapshot.removeFromSuperlayer()
})
}
layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
if bounce {
let values: [NSNumber] = [1.0, 1.14, 1.0]
self.avatarBackgroundPatternContentsLayer.animateKeyframes(values: values, duration: 0.4, keyPath: "sublayerTransform.scale")
}
}
private func loadPatternFromFile() {
guard let component = self.component else {
return
}
guard let patternContentsTarget = self.patternContentsTarget else {
return
}
guard let patternFile = self.patternFile else {
return
}
if component.context.animationRenderer.loadFirstFrameSynchronously(target: patternContentsTarget, cache: component.context.animationCache, itemId: patternFile.resource.id.stringRepresentation, size: CGSize(width: 96, height: 96)) {
self.updatePatternLayerImages(animated: false)
} else {
let animated = self.patternContentsTarget?.contents == nil
self.patternImageDisposable = component.context.animationRenderer.loadFirstFrame(
target: patternContentsTarget,
cache: component.context.animationCache,
itemId: patternFile.resource.id.stringRepresentation,
size: CGSize(width: 96, height: 96),
fetch: animationCacheFetchFile(
postbox: component.context.account.postbox,
userLocation: .other,
userContentType: .sticker,
resource: .media(media: .standalone(media: patternFile), resource: patternFile.resource),
type: AnimationCacheAnimationType(file: patternFile),
keyframeOnly: false,
customColor: .white
),
completion: { [weak self] _, _ in
guard let self else {
return
}
self.updatePatternLayerImages(animated: animated)
}
)
}
}
private func updatePatternLayerImages(animated: Bool) {
let image = self.patternContentsTarget?.contents
for patternContentLayer in self.avatarPatternContentLayers {
patternContentLayer.contents = image
if image != nil && animated {
patternContentLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
}
func update(component: PeerInfoCoverComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.currentSize = availableSize
if previousComponent?.subject?.fileId != component.subject?.fileId {
if let fileId = component.subject?.fileId, fileId != 0 {
if self.patternContentsTarget == nil {
self.patternContentsTarget = PatternContentsTarget(imageUpdated: { [weak self] hadContents in
guard let self else {
return
}
self.updatePatternLayerImages(animated: !hadContents)
})
}
self.patternFile = nil
self.patternFileDisposable?.dispose()
self.patternFileDisposable = nil
self.patternImageDisposable?.dispose()
if let file = component.files[fileId] {
self.patternFile = file
self.loadPatternFromFile()
} else {
self.patternFileDisposable = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> deliverOnMainQueue).startStrict(next: { [weak self] files in
guard let self else {
return
}
if let file = files[fileId] {
self.patternFile = file
self.loadPatternFromFile()
}
})
}
} else {
self.patternContentsTarget = nil
self.patternFileDisposable?.dispose()
self.patternFileDisposable = nil
self.patternFile = nil
self.updatePatternLayerImages(animated: false)
}
}
self.state = state
let backgroundColor: UIColor
let secondaryBackgroundColor: UIColor
if let subject = component.subject, let colors = subject.colors(context: component.context, isDark: component.isDark) {
backgroundColor = colors.0
secondaryBackgroundColor = colors.1
} else {
backgroundColor = .clear
secondaryBackgroundColor = .clear
}
let gradientWidth: CGFloat
let gradientHeight: CGFloat = component.defaultHeight
if case .custom = component.subject {
gradientWidth = gradientHeight
self.backgroundView.backgroundColor = backgroundColor
self.backgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: component.avatarCenter.y / gradientHeight)
self.backgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.backgroundGradientLayer.type = .radial
self.backgroundGradientLayer.colors = [secondaryBackgroundColor.cgColor, backgroundColor.cgColor]
} else if case .status = component.subject {
gradientWidth = availableSize.width
self.backgroundView.backgroundColor = secondaryBackgroundColor
self.backgroundGradientLayer.startPoint = component.gradientCenter
self.backgroundGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
self.backgroundGradientLayer.type = .radial
self.backgroundGradientLayer.colors = [backgroundColor.cgColor, secondaryBackgroundColor.cgColor]
} else {
gradientWidth = availableSize.width
self.backgroundView.backgroundColor = secondaryBackgroundColor
self.backgroundGradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
self.backgroundGradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0)
self.backgroundGradientLayer.type = .axial
self.backgroundGradientLayer.colors = [backgroundColor.cgColor, secondaryBackgroundColor.cgColor]
}
self.backgroundGradientLayer.anchorPoint = CGPoint(x: 0.0, y: 1.0)
let backgroundGradientFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - gradientWidth) / 2.0), y: component.gradientOnTop ? 0.0 : availableSize.height - gradientHeight), size: CGSize(width: gradientWidth, height: gradientHeight))
if !transition.animation.isImmediate {
let previousPosition = self.backgroundGradientLayer.position
let updatedPosition = CGPoint(x: backgroundGradientFrame.minX, y: backgroundGradientFrame.maxY)
self.backgroundGradientLayer.bounds = CGRect(origin: CGPoint(), size: backgroundGradientFrame.size)
self.backgroundGradientLayer.position = updatedPosition
transition.containedViewLayoutTransition.animatePositionAdditive(layer: self.backgroundGradientLayer, offset: CGPoint(x: previousPosition.x - updatedPosition.x, y: previousPosition.y - updatedPosition.y))
} else {
self.backgroundGradientLayer.frame = backgroundGradientFrame
}
let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: -1000.0 + availableSize.height), size: CGSize(width: availableSize.width, height: 1000.0))
transition.containedViewLayoutTransition.updateFrameAdditive(view: self.backgroundView, frame: backgroundFrame)
let avatarPatternFrame = CGSize(width: 380.0, height: floor(component.defaultHeight * 1.0)).centered(around: component.avatarCenter)
transition.setFrame(layer: self.avatarBackgroundPatternContentsLayer, frame: avatarPatternFrame)
if case let .custom(_, _, patternColor, _) = component.subject, let patternColor {
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
self.avatarBackgroundPatternContentsLayer.colors = [
patternColor.withAlphaComponent(0.6).cgColor,
patternColor.withAlphaComponent(0.0).cgColor
]
} else if case let .status(status) = component.subject, case let .starGift(_, _, _, _, _, _, _, patternColorValue, _) = status.content {
let patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue))
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
self.avatarBackgroundPatternContentsLayer.colors = [
patternColor.withAlphaComponent(0.6).cgColor,
patternColor.withAlphaComponent(0.0).cgColor
]
} else {
if component.subject?.colors(context: component.context, isDark: component.isDark) != nil {
self.avatarBackgroundPatternContentsLayer.compositingFilter = "overlayBlendMode"
self.avatarBackgroundPatternContentsLayer.colors = [
UIColor(white: 0.0, alpha: 0.6).cgColor,
UIColor(white: 0.0, alpha: 0.0).cgColor
]
} else {
self.avatarBackgroundPatternContentsLayer.compositingFilter = nil
let baseWhite: CGFloat = component.isDark ? 0.5 : 0.3
self.avatarBackgroundPatternContentsLayer.colors = [
UIColor(white: baseWhite, alpha: 0.6).cgColor,
UIColor(white: baseWhite, alpha: 0.0).cgColor
]
}
}
switch component.subject {
case .custom, .status:
self.avatarBackgroundGradientLayer.isHidden = true
default:
self.avatarBackgroundGradientLayer.isHidden = component.subject?.colors(context: component.context, isDark: component.isDark) == nil
}
transition.setFrame(layer: self.avatarBackgroundGradientLayer, frame: CGSize(width: 300.0, height: 300.0).centered(around: component.avatarCenter))
transition.setAlpha(layer: self.avatarBackgroundGradientLayer, alpha: 1.0 - component.avatarTransitionFraction)
let backgroundPatternContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height), size: CGSize(width: availableSize.width, height: 0.0))
transition.containedViewLayoutTransition.updateFrameAdditive(view: self.backgroundPatternContainer, frame: backgroundPatternContainerFrame)
transition.setAlpha(view: self.backgroundPatternContainer, alpha: component.patternTransitionFraction)
var baseDistance: CGFloat = component.avatarSize.width / 2.0 + 22.0
var baseRowDistance: CGFloat = 28.0
var baseItemSize: CGFloat = 26.0
if availableSize.width <= 60.0 {
baseDistance *= 0.35
baseRowDistance *= 0.3
baseItemSize *= 0.4
} else if availableSize.width < 150.0 {
baseDistance *= 0.6
baseRowDistance *= 0.6
baseItemSize *= 0.83
}
var avatarBackgroundPatternLayerCount = 0
let lokiRng = LokiRng(seed0: 123, seed1: 0, seed2: 0)
let numRows = 5
for row in 0 ..< numRows {
let avatarPatternCount: Int = 7
let avatarPatternAngleSpan: CGFloat = CGFloat.pi * 2.0 / CGFloat(avatarPatternCount - 1)
for i in 0 ..< avatarPatternCount - 1 {
let baseItemDistance: CGFloat = baseDistance + CGFloat(row) * baseRowDistance
let itemDistanceFraction = max(0.0, min(1.0, baseItemDistance / (baseDistance * 2.0)))
let itemScaleFraction = patternScaleValueAt(fraction: component.avatarTransitionFraction * 1.6, t: itemDistanceFraction, reverse: false)
let itemDistance = baseItemDistance * (1.0 - itemScaleFraction) + 20.0 * itemScaleFraction
var itemAngle: CGFloat
itemAngle = -CGFloat.pi * 0.5 + CGFloat(i) * avatarPatternAngleSpan
if row % 2 != 0 {
itemAngle += avatarPatternAngleSpan * 0.5
}
let itemPosition = CGPoint(x: avatarPatternFrame.width * 0.5 + cos(itemAngle) * itemDistance, y: avatarPatternFrame.height * 0.5 + sin(itemAngle) * itemDistance)
var itemScale: CGFloat
itemScale = 0.7 + CGFloat(lokiRng.next()) * (1.0 - 0.7)
let itemSize: CGFloat = floor(baseItemSize * itemScale)
let itemFrame = CGSize(width: itemSize, height: itemSize).centered(around: itemPosition)
let itemLayer: SimpleLayer
if self.avatarPatternContentLayers.count > avatarBackgroundPatternLayerCount {
itemLayer = self.avatarPatternContentLayers[avatarBackgroundPatternLayerCount]
} else {
itemLayer = SimpleLayer()
itemLayer.contents = self.patternContentsTarget?.contents
self.avatarBackgroundPatternMaskLayer.addSublayer(itemLayer)
self.avatarPatternContentLayers.append(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.layerTintColor = UIColor(white: 0.0, alpha: 0.8).cgColor
transition.setAlpha(layer: itemLayer, alpha: 1.0 - itemScaleFraction)
avatarBackgroundPatternLayerCount += 1
}
}
if avatarBackgroundPatternLayerCount > self.avatarPatternContentLayers.count {
for i in avatarBackgroundPatternLayerCount ..< self.avatarPatternContentLayers.count {
self.avatarPatternContentLayers[i].removeFromSuperlayer()
}
self.avatarPatternContentLayers.removeSubrange(avatarBackgroundPatternLayerCount ..< self.avatarPatternContentLayers.count)
}
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,911 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import AnimationCache
import MultiAnimationRenderer
import TelegramCore
import AccountContext
import SwiftSignalKit
import EmojiTextAttachmentView
import LokiRng
import TextFormat
import HierarchyTrackingLayer
public final class PeerInfoGiftsCoverComponent: Component {
public let context: AccountContext
public let peerId: EnginePeer.Id
public let giftsContext: ProfileGiftsContext
public let hasBackground: Bool
public let avatarCenter: CGPoint
public let avatarSize: CGSize
public let defaultHeight: CGFloat
public let avatarTransitionFraction: CGFloat
public let statusBarHeight: CGFloat
public let topLeftButtonsSize: CGSize
public let topRightButtonsSize: CGSize
public let titleWidth: CGFloat
public let bottomHeight: CGFloat
public let action: (ProfileGiftsContext.State.StarGift) -> Void
public init(
context: AccountContext,
peerId: EnginePeer.Id,
giftsContext: ProfileGiftsContext,
hasBackground: Bool,
avatarCenter: CGPoint,
avatarSize: CGSize,
defaultHeight: CGFloat,
avatarTransitionFraction: CGFloat,
statusBarHeight: CGFloat,
topLeftButtonsSize: CGSize,
topRightButtonsSize: CGSize,
titleWidth: CGFloat,
bottomHeight: CGFloat,
action: @escaping (ProfileGiftsContext.State.StarGift) -> Void
) {
self.context = context
self.peerId = peerId
self.giftsContext = giftsContext
self.hasBackground = hasBackground
self.avatarCenter = avatarCenter
self.avatarSize = avatarSize
self.defaultHeight = defaultHeight
self.avatarTransitionFraction = avatarTransitionFraction
self.statusBarHeight = statusBarHeight
self.topLeftButtonsSize = topLeftButtonsSize
self.topRightButtonsSize = topRightButtonsSize
self.titleWidth = titleWidth
self.bottomHeight = bottomHeight
self.action = action
}
public static func ==(lhs: PeerInfoGiftsCoverComponent, rhs: PeerInfoGiftsCoverComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.hasBackground != rhs.hasBackground {
return false
}
if lhs.avatarCenter != rhs.avatarCenter {
return false
}
if lhs.avatarSize != rhs.avatarSize {
return false
}
if lhs.defaultHeight != rhs.defaultHeight {
return false
}
if lhs.avatarTransitionFraction != rhs.avatarTransitionFraction {
return false
}
if lhs.statusBarHeight != rhs.statusBarHeight {
return false
}
if lhs.topLeftButtonsSize != rhs.topLeftButtonsSize {
return false
}
if lhs.topRightButtonsSize != rhs.topRightButtonsSize {
return false
}
if lhs.titleWidth != rhs.titleWidth {
return false
}
if lhs.bottomHeight != rhs.bottomHeight {
return false
}
return true
}
public final class View: UIView {
private var currentSize: CGSize?
private var component: PeerInfoGiftsCoverComponent?
private var state: EmptyComponentState?
private var giftsDisposable: Disposable?
private var gifts: [ProfileGiftsContext.State.StarGift] = []
private var appliedGiftIds: [Int64] = []
private var iconLayers: [AnyHashable: GiftIconLayer] = [:]
private var iconPositions: [PositionGenerator.Position] = []
private let seed = UInt(Date().timeIntervalSince1970)
private let trackingLayer = HierarchyTrackingLayer()
private var isCurrentlyInHierarchy = false
private var isUpdating = false
override public init(frame: CGRect) {
super.init(frame: frame)
self.clipsToBounds = true
self.layer.addSublayer(self.trackingLayer)
self.trackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.isCurrentlyInHierarchy = true
self.updateAnimations()
}
self.trackingLayer.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.isCurrentlyInHierarchy = false
}
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped(_:))))
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.giftsDisposable?.dispose()
}
@objc private func tapped(_ gestureRecognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
let location = gestureRecognizer.location(in: self)
for (_, iconLayer) in self.iconLayers {
if iconLayer.frame.contains(location) {
component.action(iconLayer.gift)
break
}
}
}
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for (_, iconLayer) in self.iconLayers {
if iconLayer.frame.contains(point) {
return true
}
}
return false
}
func updateAnimations() {
var index = 0
for (_, iconLayer) in self.iconLayers {
if self.isCurrentlyInHierarchy {
iconLayer.startAnimations(index: index)
}
index += 1
}
}
private var scheduledAnimateIn = false
public func willAnimateIn() {
self.scheduledAnimateIn = true
for (_, layer) in self.iconLayers {
layer.opacity = 0.0
}
}
public func animateIn() {
guard let _ = self.currentSize, let component = self.component else {
return
}
self.scheduledAnimateIn = false
for (_, layer) in self.iconLayers {
layer.opacity = 1.0
layer.animatePosition(
from: component.avatarCenter,
to: layer.position,
duration: 0.4,
timingFunction: kCAMediaTimingFunctionSpring
)
}
}
func update(component: PeerInfoGiftsCoverComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
self.state = state
let previousCurrentSize = self.currentSize
self.currentSize = availableSize
let iconSize = CGSize(width: 32.0, height: 32.0)
let giftIds = self.gifts.map { gift in
if case let .unique(gift) = gift.gift {
return gift.id
} else {
return 0
}
}
if !giftIds.isEmpty && (self.iconPositions.isEmpty || previousCurrentSize?.width != availableSize.width || (previousComponent != nil && previousComponent?.hasBackground != component.hasBackground) || self.appliedGiftIds != giftIds) {
var avatarCenter = component.avatarCenter
if avatarCenter.y < 0.0 {
avatarCenter.y = component.statusBarHeight + 75.0
}
var excludeRects: [CGRect] = []
if component.statusBarHeight > 0.0 {
excludeRects.append(CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: component.statusBarHeight + 4.0)))
}
excludeRects.append(CGRect(origin: CGPoint(x: 0.0, y: component.statusBarHeight), size: component.topLeftButtonsSize))
excludeRects.append(CGRect(origin: CGPoint(x: availableSize.width - component.topRightButtonsSize.width, y: component.statusBarHeight), size: component.topRightButtonsSize))
excludeRects.append(CGRect(origin: CGPoint(x: floor((availableSize.width - component.titleWidth) / 2.0), y: avatarCenter.y + component.avatarSize.height / 2.0 + 6.0), size: CGSize(width: component.titleWidth, height: 100.0)))
if component.bottomHeight > 0.0 {
excludeRects.append(CGRect(origin: CGPoint(x: 0.0, y: component.defaultHeight - component.bottomHeight), size: CGSize(width: availableSize.width, height: component.bottomHeight)))
}
let positionGenerator = PositionGenerator(
containerSize: CGSize(width: availableSize.width, height: component.defaultHeight),
centerFrame: component.avatarSize.centered(around: avatarCenter),
exclusionZones: excludeRects,
minimumDistance: 42.0,
edgePadding: 5.0,
seed: self.seed
)
self.iconPositions = positionGenerator.generatePositions(count: 12, itemSize: iconSize)
}
self.appliedGiftIds = giftIds
if self.giftsDisposable == nil {
self.giftsDisposable = combineLatest(
queue: Queue.mainQueue(),
component.giftsContext.state,
component.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId))
|> map { peer -> Int64? in
if case let .user(user) = peer, case let .starGift(id, _, _, _, _, _, _, _, _) = user.emojiStatus?.content {
return id
}
return nil
}
|> distinctUntilChanged
).start(next: { [weak self] state, giftStatusId in
guard let self else {
return
}
let pinnedGifts = state.gifts.filter { gift in
if gift.pinnedToTop {
if case let .unique(uniqueGift) = gift.gift {
return uniqueGift.id != giftStatusId
}
}
return false
}
self.gifts = pinnedGifts
if !self.isUpdating {
self.state?.updated(transition: .spring(duration: 0.4))
}
})
}
var validIds = Set<AnyHashable>()
var index = 0
for gift in self.gifts.prefix(12) {
guard index < self.iconPositions.count else {
break
}
let id: AnyHashable
if case let .unique(uniqueGift) = gift.gift {
id = uniqueGift.slug
} else {
id = index
}
validIds.insert(id)
var iconTransition = transition
let iconPosition = self.iconPositions[index]
let iconLayer: GiftIconLayer
if let current = self.iconLayers[id] {
iconLayer = current
} else {
iconTransition = .immediate
iconLayer = GiftIconLayer(context: component.context, gift: gift, size: iconSize, glowing: component.hasBackground)
self.iconLayers[id] = iconLayer
self.layer.addSublayer(iconLayer)
if self.scheduledAnimateIn {
iconLayer.opacity = 0.0
} else {
iconLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
iconLayer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
iconLayer.startAnimations(index: index)
}
iconLayer.glowing = component.hasBackground
let itemDistanceFraction = max(0.0, min(0.5, (iconPosition.distance - component.avatarSize.width / 2.0) / 144.0))
let itemScaleFraction = patternScaleValueAt(fraction: min(1.0, component.avatarTransitionFraction * 1.33), t: itemDistanceFraction, reverse: false)
func interpolatePosition(from: PositionGenerator.Position, to: PositionGenerator.Position, t: CGFloat) -> PositionGenerator.Position {
let clampedT = max(0, min(1, t))
let interpolatedDistance = from.distance + (to.distance - from.distance) * clampedT
let interpolatedAngle = from.angle + (to.angle - from.angle) * clampedT
return PositionGenerator.Position(distance: interpolatedDistance, angle: interpolatedAngle, scale: from.scale)
}
let toAngle: CGFloat = .pi * 0.18
let centerPosition = PositionGenerator.Position(distance: 0.0, angle: iconPosition.angle + toAngle, scale: iconPosition.scale)
let effectivePosition = interpolatePosition(from: iconPosition, to: centerPosition, t: itemScaleFraction)
let effectiveAngle = toAngle * itemScaleFraction
let absolutePosition = getAbsolutePosition(position: effectivePosition, centerPoint: component.avatarCenter)
iconTransition.setBounds(layer: iconLayer, bounds: CGRect(origin: .zero, size: iconSize))
iconTransition.setPosition(layer: iconLayer, position: absolutePosition)
iconLayer.updateRotation(effectiveAngle, transition: iconTransition)
iconTransition.setScale(layer: iconLayer, scale: iconPosition.scale * (1.0 - itemScaleFraction))
if !self.scheduledAnimateIn {
iconTransition.setAlpha(layer: iconLayer, alpha: 1.0 - itemScaleFraction)
}
index += 1
}
var removeIds: [AnyHashable] = []
for (id, layer) in self.iconLayers {
if !validIds.contains(id) {
removeIds.append(id)
layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
layer.removeFromSuperlayer()
})
}
}
for id in removeIds {
self.iconLayers.removeValue(forKey: id)
}
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)
}
}
private var shadowImage: UIImage? = {
return generateImage(CGSize(width: 44.0, height: 44.0), rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size))
var locations: [CGFloat] = [0.0, 0.3, 1.0]
let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.65).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: size.width / 2.0, y: size.height / 2.0), startRadius: 0.0, endCenter: CGPoint(x: size.width / 2.0, y: size.height / 2.0), endRadius: size.width / 2.0, options: .drawsAfterEndLocation)
})
}()
private final class StarsEffectLayer: SimpleLayer {
private let emitterLayer = CAEmitterLayer()
override init() {
super.init()
self.addSublayer(self.emitterLayer)
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup(color: UIColor, size: CGSize) {
self.color = color
let emitter = CAEmitterCell()
emitter.name = "emitter"
emitter.contents = UIImage(bundleImageName: "Premium/Stars/Particle")?.cgImage
emitter.birthRate = 8.0
emitter.lifetime = 2.0
emitter.velocity = 0.1
emitter.scale = (size.width / 40.0) * 0.12
emitter.scaleRange = 0.02
emitter.alphaRange = 0.1
emitter.emissionRange = .pi * 2.0
let staticColors: [Any] = [
color.withAlphaComponent(0.0).cgColor,
color.withAlphaComponent(0.58).cgColor,
color.withAlphaComponent(0.58).cgColor,
color.withAlphaComponent(0.0).cgColor
]
let staticColorBehavior = CAEmitterCell.createEmitterBehavior(type: "colorOverLife")
staticColorBehavior.setValue(staticColors, forKey: "colors")
emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors")
self.emitterLayer.emitterCells = [emitter]
}
private var color: UIColor?
func update(color: UIColor, size: CGSize) {
if self.color != color {
self.setup(color: color, size: size)
}
self.emitterLayer.seed = UInt32.random(in: .min ..< .max)
self.emitterLayer.emitterShape = .circle
self.emitterLayer.emitterSize = size
self.emitterLayer.emitterMode = .surface
self.emitterLayer.frame = CGRect(origin: .zero, size: size)
self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
}
private class GiftIconLayer: SimpleLayer {
private let context: AccountContext
let gift: ProfileGiftsContext.State.StarGift
private let size: CGSize
var glowing: Bool {
didSet {
self.shadowLayer.opacity = self.glowing ? 1.0 : 0.0
let color: UIColor
if self.glowing {
color = .white
} else if let layerTintColor = self.shadowLayer.layerTintColor {
color = UIColor(cgColor: layerTintColor)
} else {
color = .white
}
let side = floor(self.size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: self.size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: color, size: starsFrame.size)
}
}
let shadowLayer = SimpleLayer()
let starsLayer = StarsEffectLayer()
let animationLayer: InlineStickerItemLayer
override init(layer: Any) {
guard let layer = layer as? GiftIconLayer else {
fatalError()
}
let context = layer.context
let gift = layer.gift
let size = layer.size
let glowing = layer.glowing
var file: TelegramMediaFile?
var color: UIColor = .white
switch gift.gift {
case let .generic(gift):
file = gift.file
case let .unique(gift):
for attribute in gift.attributes {
if case let .model(_, fileValue, _) = attribute {
file = fileValue
} else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute {
color = UIColor(rgb: UInt32(bitPattern: innerColor))
}
}
}
let emoji = ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file?.fileId.id ?? 0,
file: file
)
self.animationLayer = InlineStickerItemLayer(
context: .account(context),
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emoji,
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: true,
placeholderColor: UIColor.white.withAlphaComponent(0.2),
pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0),
loopCount: 1
)
self.shadowLayer.contents = shadowImage?.cgImage
self.shadowLayer.layerTintColor = color.cgColor
self.shadowLayer.opacity = glowing ? 1.0 : 0.0
self.context = context
self.gift = gift
self.size = size
self.glowing = glowing
super.init()
let side = floor(size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size)
self.addSublayer(self.shadowLayer)
self.addSublayer(self.starsLayer)
self.addSublayer(self.animationLayer)
}
init(
context: AccountContext,
gift: ProfileGiftsContext.State.StarGift,
size: CGSize,
glowing: Bool
) {
self.context = context
self.gift = gift
self.size = size
self.glowing = glowing
var file: TelegramMediaFile?
var color: UIColor = .white
switch gift.gift {
case let .generic(gift):
file = gift.file
case let .unique(gift):
for attribute in gift.attributes {
if case let .model(_, fileValue, _) = attribute {
file = fileValue
} else if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute {
color = UIColor(rgb: UInt32(bitPattern: innerColor))
}
}
}
let emoji = ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file?.fileId.id ?? 0,
file: file
)
self.animationLayer = InlineStickerItemLayer(
context: .account(context),
userLocation: .other,
attemptSynchronousLoad: false,
emoji: emoji,
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: true,
placeholderColor: UIColor.white.withAlphaComponent(0.2),
pointSize: CGSize(width: size.width * 2.0, height: size.height * 2.0),
loopCount: 1
)
self.shadowLayer.contents = shadowImage?.cgImage
self.shadowLayer.layerTintColor = color.cgColor
self.shadowLayer.opacity = glowing ? 1.0 : 0.0
super.init()
let side = floor(size.width * 1.25)
let starsFrame = CGSize(width: side, height: side).centered(in: CGRect(origin: .zero, size: size))
self.starsLayer.frame = starsFrame
self.starsLayer.update(color: glowing ? .white : color, size: starsFrame.size)
self.addSublayer(self.shadowLayer)
self.addSublayer(self.starsLayer)
self.addSublayer(self.animationLayer)
}
required init?(coder: NSCoder) {
preconditionFailure()
}
override func layoutSublayers() {
self.shadowLayer.frame = CGRect(origin: .zero, size: self.bounds.size).insetBy(dx: -8.0, dy: -8.0)
self.animationLayer.bounds = CGRect(origin: .zero, size: self.bounds.size)
self.animationLayer.position = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
}
func updateRotation(_ angle: CGFloat, transition: ComponentTransition) {
self.animationLayer.transform = CATransform3DMakeRotation(angle, 0.0, 0.0, 1.0)
}
func startAnimations(index: Int) {
let beginTime = Double(index) * 1.5
if self.animation(forKey: "hover") == nil {
let upDistance = CGFloat.random(in: 1.0 ..< 2.0)
let downDistance = CGFloat.random(in: 1.0 ..< 2.0)
let hoverDuration = TimeInterval.random(in: 3.5 ..< 4.5)
let hoverAnimation = CABasicAnimation(keyPath: "transform.translation.y")
hoverAnimation.duration = hoverDuration
hoverAnimation.fromValue = -upDistance
hoverAnimation.toValue = downDistance
hoverAnimation.autoreverses = true
hoverAnimation.repeatCount = .infinity
hoverAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
hoverAnimation.beginTime = beginTime
hoverAnimation.isAdditive = true
self.add(hoverAnimation, forKey: "hover")
}
if self.animationLayer.animation(forKey: "wiggle") == nil {
let fromRotationAngle = CGFloat.random(in: 0.025 ..< 0.05)
let toRotationAngle = CGFloat.random(in: 0.025 ..< 0.05)
let wiggleDuration = TimeInterval.random(in: 2.0 ..< 3.0)
let wiggleAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
wiggleAnimation.duration = wiggleDuration
wiggleAnimation.fromValue = -fromRotationAngle
wiggleAnimation.toValue = toRotationAngle
wiggleAnimation.autoreverses = true
wiggleAnimation.repeatCount = .infinity
wiggleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
wiggleAnimation.beginTime = beginTime
wiggleAnimation.isAdditive = true
self.animationLayer.add(wiggleAnimation, forKey: "wiggle")
}
if self.shadowLayer.animation(forKey: "glow") == nil {
let glowDuration = TimeInterval.random(in: 2.0 ..< 3.0)
let glowAnimation = CABasicAnimation(keyPath: "transform.scale")
glowAnimation.duration = glowDuration
glowAnimation.fromValue = 1.0
glowAnimation.toValue = 1.2
glowAnimation.autoreverses = true
glowAnimation.repeatCount = .infinity
glowAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
glowAnimation.beginTime = beginTime
self.shadowLayer.add(glowAnimation, forKey: "glow")
}
}
}
private struct PositionGenerator {
struct Position {
let distance: CGFloat
let angle: CGFloat
let scale: CGFloat
var relativeCartesian: CGPoint {
return CGPoint(
x: self.distance * cos(self.angle),
y: self.distance * sin(self.angle)
)
}
}
let containerSize: CGSize
let centerFrame: CGRect
let exclusionZones: [CGRect]
let minimumDistance: CGFloat
let edgePadding: CGFloat
let scaleRange: (min: CGFloat, max: CGFloat)
let innerOrbitRange: (min: CGFloat, max: CGFloat)
let outerOrbitRange: (min: CGFloat, max: CGFloat)
let innerOrbitCount: Int
private let lokiRng: LokiRng
init(
containerSize: CGSize,
centerFrame: CGRect,
exclusionZones: [CGRect],
minimumDistance: CGFloat,
edgePadding: CGFloat,
seed: UInt,
scaleRange: (min: CGFloat, max: CGFloat) = (0.7, 1.15),
innerOrbitRange: (min: CGFloat, max: CGFloat) = (1.4, 2.2),
outerOrbitRange: (min: CGFloat, max: CGFloat) = (2.5, 3.6),
innerOrbitCount: Int = 4
) {
self.containerSize = containerSize
self.centerFrame = centerFrame
self.exclusionZones = exclusionZones
self.minimumDistance = minimumDistance
self.edgePadding = edgePadding
self.scaleRange = scaleRange
self.innerOrbitRange = innerOrbitRange
self.outerOrbitRange = outerOrbitRange
self.innerOrbitCount = innerOrbitCount
self.lokiRng = LokiRng(seed0: seed, seed1: 0, seed2: 0)
}
func generatePositions(count: Int, itemSize: CGSize) -> [Position] {
var positions: [Position] = []
let centerPoint = CGPoint(x: self.centerFrame.midX, y: self.centerFrame.midY)
let centerRadius = min(self.centerFrame.width, self.centerFrame.height) / 2.0
let maxAttempts = count * 200
var attempts = 0
var leftPositions = 0
var rightPositions = 0
let innerCount = min(self.innerOrbitCount, count)
while positions.count < innerCount && attempts < maxAttempts {
attempts += 1
let placeOnLeftSide = rightPositions > leftPositions
let orbitRangeSize = self.innerOrbitRange.max - self.innerOrbitRange.min
let orbitDistanceFactor = self.innerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next())
let distance = orbitDistanceFactor * centerRadius
let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi
let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2)
let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next())
let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint)
if absolutePosition.x - itemSize.width/2 < self.edgePadding ||
absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding ||
absolutePosition.y - itemSize.height/2 < self.edgePadding ||
absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding {
continue
}
let itemRect = CGRect(
x: absolutePosition.x - itemSize.width/2,
y: absolutePosition.y - itemSize.height/2,
width: itemSize.width,
height: itemSize.height
)
if self.isValidPosition(itemRect, existingPositions: positions.map {
getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint)
}, itemSize: itemSize) {
let scaleRangeSize = max(self.scaleRange.min + 0.1, 0.75) - self.scaleRange.max
let scale = self.scaleRange.max + scaleRangeSize * CGFloat(self.lokiRng.next())
positions.append(Position(distance: distance, angle: angle, scale: scale))
if absolutePosition.x < centerPoint.x {
leftPositions += 1
} else {
rightPositions += 1
}
}
}
let maxPossibleDistance = hypot(self.containerSize.width, self.containerSize.height) / 2
while positions.count < count && attempts < maxAttempts {
attempts += 1
let placeOnLeftSide = rightPositions >= leftPositions
let orbitRangeSize = self.outerOrbitRange.max - self.outerOrbitRange.min
let orbitDistanceFactor = self.outerOrbitRange.min + orbitRangeSize * CGFloat(self.lokiRng.next())
let distance = orbitDistanceFactor * centerRadius
let angleRange: CGFloat = placeOnLeftSide ? .pi : .pi
let angleOffset: CGFloat = placeOnLeftSide ? .pi/2 : -(.pi/2)
let angle = angleOffset + angleRange * CGFloat(self.lokiRng.next())
let absolutePosition = getAbsolutePosition(distance: distance, angle: angle, centerPoint: centerPoint)
if absolutePosition.x - itemSize.width/2 < self.edgePadding ||
absolutePosition.x + itemSize.width/2 > self.containerSize.width - self.edgePadding ||
absolutePosition.y - itemSize.height/2 < self.edgePadding ||
absolutePosition.y + itemSize.height/2 > self.containerSize.height - self.edgePadding {
continue
}
let itemRect = CGRect(
x: absolutePosition.x - itemSize.width/2,
y: absolutePosition.y - itemSize.height/2,
width: itemSize.width,
height: itemSize.height
)
if self.isValidPosition(itemRect, existingPositions: positions.map {
getAbsolutePosition(distance: $0.distance, angle: $0.angle, centerPoint: centerPoint)
}, itemSize: itemSize) {
let normalizedDistance = min(distance / maxPossibleDistance, 1.0)
let scale = self.scaleRange.max - normalizedDistance * (self.scaleRange.max - self.scaleRange.min)
positions.append(Position(distance: distance, angle: angle, scale: scale))
if absolutePosition.x < centerPoint.x {
leftPositions += 1
} else {
rightPositions += 1
}
}
}
return positions
}
func getAbsolutePosition(distance: CGFloat, angle: CGFloat, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + distance * cos(angle),
y: centerPoint.y + distance * sin(angle)
)
}
private func isValidPosition(_ rect: CGRect, existingPositions: [CGPoint], itemSize: CGSize) -> Bool {
if rect.minX < self.edgePadding || rect.maxX > self.containerSize.width - self.edgePadding ||
rect.minY < self.edgePadding || rect.maxY > self.containerSize.height - self.edgePadding {
return false
}
for zone in self.exclusionZones {
if rect.intersects(zone) {
return false
}
}
let effectiveMinDistance = existingPositions.count > 5 ? max(self.minimumDistance * 0.7, 10.0) : self.minimumDistance
for existingPosition in existingPositions {
let distance = hypot(existingPosition.x - rect.midX, existingPosition.y - rect.midY)
if distance < effectiveMinDistance {
return false
}
}
return true
}
}
private func getAbsolutePosition(position: PositionGenerator.Position, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + position.distance * cos(position.angle),
y: centerPoint.y + position.distance * sin(position.angle)
)
}
private func getAbsolutePosition(distance: CGFloat, angle: CGFloat, centerPoint: CGPoint) -> CGPoint {
return CGPoint(
x: centerPoint.x + distance * cos(angle),
y: centerPoint.y + distance * sin(angle)
)
}
private func windowFunction(t: CGFloat) -> CGFloat {
return bezierPoint(0.6, 0.0, 0.4, 1.0, t)
}
private func patternScaleValueAt(fraction: CGFloat, t: CGFloat, reverse: Bool) -> CGFloat {
let windowSize: CGFloat = 0.8
let effectiveT: CGFloat
let windowStartOffset: CGFloat
let windowEndOffset: CGFloat
if reverse {
effectiveT = 1.0 - t
windowStartOffset = 1.0
windowEndOffset = -windowSize
} else {
effectiveT = t
windowStartOffset = -windowSize
windowEndOffset = 1.0
}
let windowPosition = (1.0 - fraction) * windowStartOffset + fraction * windowEndOffset
let windowT = max(0.0, min(windowSize, effectiveT - windowPosition)) / windowSize
let localT = 1.0 - windowFunction(t: windowT)
return localT
}
@@ -0,0 +1,23 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoPaneNode",
module_name = "PeerInfoPaneNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Postbox",
"//submodules/TelegramCore",
"//submodules/AsyncDisplayKit",
"//submodules/Display",
"//submodules/TelegramPresentationData",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,129 @@
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import UIKit
import Display
import TelegramPresentationData
public enum PeerInfoPaneKey: Int32 {
case botPreview
case members
case stories
case gifts
case media
case savedMessagesChats
case savedMessages
case files
case music
case voice
case links
case gifs
case groupsInCommon
case similarChannels
case similarBots
case storyArchive
public init(tab: TelegramProfileTab) {
switch tab {
case .files:
self = .files
case .gifs:
self = .gifs
case .gifts:
self = .gifts
case .links:
self = .links
case .media:
self = .media
case .music:
self = .music
case .posts:
self = .stories
case .voice:
self = .voice
}
}
public var tab: TelegramProfileTab? {
switch self {
case .stories:
return .posts
case .gifts:
return .gifts
case .media:
return .media
case .files:
return .files
case .music:
return .music
case .voice:
return .voice
case .links:
return .links
case .gifs:
return .gifs
default:
return nil
}
}
}
public struct PeerInfoStatusData: Equatable {
public var text: String
public var isActivity: Bool
public var isHiddenStatus: Bool
public var key: PeerInfoPaneKey?
public init(
text: String,
isActivity: Bool,
isHiddenStatus: Bool = false,
key: PeerInfoPaneKey?
) {
self.text = text
self.isActivity = isActivity
self.isHiddenStatus = isHiddenStatus
self.key = key
}
}
public protocol PeerInfoPanelNodeNavigationContentNode: ASDisplayNode {
func update(width: CGFloat, defaultHeight: CGFloat, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) -> CGFloat
}
public protocol PeerInfoPaneNode: ASDisplayNode {
var isReady: Signal<Bool, NoError> { get }
var parentController: ViewController? { get set }
var status: Signal<PeerInfoStatusData?, NoError> { get }
var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set }
var tabBarOffset: CGFloat { get }
var navigationContentNode: PeerInfoPanelNodeNavigationContentNode? { get }
var externalDataUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set }
func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition)
func scrollToTop() -> Bool
func transferVelocity(_ velocity: CGFloat)
func cancelPreviewGestures()
func findLoadedMessage(id: MessageId) -> Message?
func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
func addToTransitionSurface(view: UIView)
func updateHiddenMedia()
func updateSelectedMessages(animated: Bool)
func ensureMessageIsVisible(id: MessageId)
}
public extension PeerInfoPaneNode {
var navigationContentNode: PeerInfoPanelNodeNavigationContentNode? {
return nil
}
var externalDataUpdated: ((ContainedViewLayoutTransition) -> Void)? {
get {
return nil
} set(value) {
}
}
}
@@ -0,0 +1,21 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoRatingComponent",
module_name = "PeerInfoRatingComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/Components/MultilineTextComponent",
"//submodules/Svg",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,450 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import Svg
private func generateNumberOffsets() -> [CGPoint] {
return [
CGPoint(x: 0.25, y: -0.33),
CGPoint(x: 0.24749999999999983, y: -0.495),
CGPoint(x: 0.0, y: -1.4025),
CGPoint(x: 0.33, y: -0.495),
CGPoint(x: 0.33, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.49500000000000005, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.165, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.495, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.49500000000000005, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.41250000000000003, y: 0.0),
CGPoint(x: 0.49500000000000005, y: 0.0),
CGPoint(x: 0.2475, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 1.2375, y: 0.0),
CGPoint(x: 1.0725, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 1.32, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 1.5675000000000001, y: 0.0),
CGPoint(x: 0.9075000000000001, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.41250000000000003),
CGPoint(x: 0.66, y: 1.32),
CGPoint(x: 0.33, y: 1.32),
CGPoint(x: 0.41250000000000003, y: 0.41250000000000003),
CGPoint(x: 0.49500000000000005, y: 0.2475),
CGPoint(x: 0.41250000000000003, y: 0.33),
CGPoint(x: 0.5775, y: 0.49500000000000005),
CGPoint(x: 0.7425, y: 0.9075000000000001),
CGPoint(x: 0.41250000000000003, y: 0.49500000000000005),
CGPoint(x: 0.5775, y: 0.5775),
CGPoint(x: 1.32, y: -0.9075),
CGPoint(x: 0.49500000000000005, y: -0.41250000000000003),
CGPoint(x: 0.2475, y: -0.9075),
CGPoint(x: 0.7425, y: -0.66),
CGPoint(x: 0.9075000000000001, y: -0.49500000000000005),
CGPoint(x: 0.66, y: -0.165),
CGPoint(x: 1.155, y: -0.16499999999999998),
CGPoint(x: 0.9075000000000001, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.0),
CGPoint(x: 0.9900000000000001, y: -0.0825),
CGPoint(x: 0.8250000000000001, y: 0.08249999999999998),
CGPoint(x: 0.41250000000000003, y: 0.0),
CGPoint(x: 0.41250000000000003, y: 0.0),
CGPoint(x: 1.2375, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 1.0725, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.9075000000000001, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.99, y: 0.0),
CGPoint(x: 0.41250000000000003, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.7425, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 0.66, y: 0.0),
CGPoint(x: 0.5775, y: 0.0),
CGPoint(x: 1.2375, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 1.32, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 0.9900000000000001, y: 0.0),
CGPoint(x: 1.0725, y: 0.0),
CGPoint(x: 1.2375, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.0),
CGPoint(x: 1.0725, y: 0.0),
CGPoint(x: 0.9075000000000001, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 0.8250000000000001, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 1.0725, y: 0.0),
CGPoint(x: 1.2375, y: 0.0),
CGPoint(x: 1.155, y: 0.0),
CGPoint(x: 1.32, y: 0.0),
]
}
let numberOffsets: [CGPoint] = generateNumberOffsets()
public final class PeerInfoRatingComponent: Component {
let backgroundColor: UIColor
let borderColor: UIColor
let foregroundColor: UIColor
let level: Int
let action: () -> Void
let debugLevel: Bool
public init(
backgroundColor: UIColor,
borderColor: UIColor,
foregroundColor: UIColor,
level: Int,
action: @escaping () -> Void,
debugLevel: Bool = false
) {
self.backgroundColor = backgroundColor
self.borderColor = borderColor
self.foregroundColor = foregroundColor
self.level = level
self.action = action
self.debugLevel = debugLevel
}
public static func ==(lhs: PeerInfoRatingComponent, rhs: PeerInfoRatingComponent) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.borderColor != rhs.borderColor {
return false
}
if lhs.foregroundColor != rhs.foregroundColor {
return false
}
if lhs.level != rhs.level {
return false
}
if lhs.debugLevel != rhs.debugLevel {
return false
}
return true
}
private struct TextLayout {
var size: CGSize
var opticalBounds: CGRect
init(size: CGSize, opticalBounds: CGRect) {
self.size = size
self.opticalBounds = opticalBounds
}
}
public final class View: UIView {
private let borderLayer: SimpleLayer
private let backgroundLayer: SimpleLayer
private var debugLevel: Int = 1
private var component: PeerInfoRatingComponent?
private weak var state: EmptyComponentState?
override public init(frame: CGRect) {
self.borderLayer = SimpleLayer()
self.backgroundLayer = SimpleLayer()
super.init(frame: frame)
self.layer.addSublayer(self.borderLayer)
self.layer.addSublayer(self.backgroundLayer)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
if component.debugLevel {
if self.debugLevel < 10 {
self.debugLevel += 1
} else {
self.debugLevel += 10
}
if self.debugLevel >= 110 {
self.debugLevel = 1
}
self.state?.updated(transition: .immediate)
} else {
self.component?.action()
}
}
}
func update(component: PeerInfoRatingComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let size = CGSize(width: 30.0, height: 30.0)
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
let previousComponent = self.component
self.component = component
self.state = state
let level: Int
if component.debugLevel {
level = self.debugLevel
} else {
level = component.level
}
let iconSize = CGSize(width: 26.0, height: 26.0)
let alwaysRedraw: Bool = component.debugLevel
if previousComponent?.level != level || previousComponent?.borderColor != component.borderColor || previousComponent?.foregroundColor != component.foregroundColor || previousComponent?.backgroundColor != component.backgroundColor || alwaysRedraw {
let weight: CGFloat = UIFont.Weight.semibold.rawValue
let width: CGFloat = -0.1
let descriptor: UIFontDescriptor
if #available(iOS 14.0, *) {
descriptor = UIFont.systemFont(ofSize: 10.0).fontDescriptor
} else {
descriptor = UIFont.systemFont(ofSize: 10.0, weight: UIFont.Weight.semibold).fontDescriptor
}
let symbolicTraits = descriptor.symbolicTraits
var updatedDescriptor: UIFontDescriptor? = descriptor.withSymbolicTraits(symbolicTraits)
updatedDescriptor = updatedDescriptor?.withDesign(.default)
if #available(iOS 14.0, *) {
updatedDescriptor = updatedDescriptor?.addingAttributes([
UIFontDescriptor.AttributeName.traits: [UIFontDescriptor.TraitKey.weight: weight]
])
}
if #available(iOS 16.0, *) {
updatedDescriptor = updatedDescriptor?.addingAttributes([
UIFontDescriptor.AttributeName.traits: [UIFontDescriptor.TraitKey.width: width]
])
}
let font: UIFont
if let updatedDescriptor {
font = UIFont(descriptor: updatedDescriptor, size: 10.0)
} else {
font = UIFont(descriptor: descriptor, size: 10.0)
}
let attributedText = NSAttributedString(string: "\(level)", attributes: [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: component.foregroundColor
])
var boundingRect = attributedText.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
boundingRect.size.width = ceil(boundingRect.size.width)
boundingRect.size.height = ceil(boundingRect.size.height)
var textLayout: TextLayout?
if let context = DrawingContext(size: boundingRect.size, scale: 0.0, opaque: false, clear: true) {
context.withContext { c in
UIGraphicsPushContext(c)
defer {
UIGraphicsPopContext()
}
attributedText.draw(at: CGPoint())
}
var minFilledLineY = Int(context.scaledSize.height) - 1
var maxFilledLineY = 0
var minFilledLineX = Int(context.scaledSize.width) - 1
var maxFilledLineX = 0
for y in 0 ..< Int(context.scaledSize.height) {
let linePtr = context.bytes.advanced(by: max(0, y) * context.bytesPerRow).assumingMemoryBound(to: UInt32.self)
for x in 0 ..< Int(context.scaledSize.width) {
let pixelPtr = linePtr.advanced(by: x)
if pixelPtr.pointee != 0 {
minFilledLineY = min(y, minFilledLineY)
maxFilledLineY = max(y, maxFilledLineY)
minFilledLineX = min(x, minFilledLineX)
maxFilledLineX = max(x, maxFilledLineX)
}
}
}
var opticalBounds = CGRect()
if minFilledLineX <= maxFilledLineX && minFilledLineY <= maxFilledLineY {
opticalBounds.origin.x = CGFloat(minFilledLineX) / context.scale
opticalBounds.origin.y = CGFloat(minFilledLineY) / context.scale
opticalBounds.size.width = CGFloat(maxFilledLineX - minFilledLineX) / context.scale
opticalBounds.size.height = CGFloat(maxFilledLineY - minFilledLineY) / context.scale
}
textLayout = TextLayout(size: boundingRect.size, opticalBounds: opticalBounds)
}
let levelIndex: Int
if level < 0 {
levelIndex = 1
} else if level <= 10 {
levelIndex = max(1, level)
} else if level <= 90 {
levelIndex = (level / 10) * 10
} else {
levelIndex = 90
}
let backgroundOffsetsY: [Int: CGFloat] = [
3: -0.8250000000000001,
7: 0.33,
40: 1.0,
60: 0.2475,
70: 0.33,
80: 0.2475,
]
let borderImage = generateImage(iconSize, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
if level < 0 {
return
}
if let url = Bundle.main.url(forResource: "profile_level\(levelIndex)_outer", withExtension: "svg"), let data = try? Data(contentsOf: url) {
if let image = generateTintedImage(image: drawSvgImage(data, size, nil, nil, 0.0, false), color: component.borderColor) {
image.draw(in: CGRect(origin: CGPoint(x: 0.0, y: backgroundOffsetsY[levelIndex] ?? 0.0), size: size), blendMode: .normal, alpha: 1.0)
}
}
})
if let previousContents = self.borderLayer.contents, CFGetTypeID(previousContents as CFTypeRef) == CGImage.typeID {
self.borderLayer.contents = borderImage!.cgImage
alphaTransition.animateContentsImage(layer: self.borderLayer, from: previousContents as! CGImage, to: borderImage!.cgImage!, duration: 0.2, curve: .easeInOut)
} else {
self.borderLayer.contents = borderImage!.cgImage
}
let backgroundImage = generateImage(iconSize, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
if level < 0 {
if let image = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/InlineRatingWarning"), color: component.backgroundColor) {
image.draw(in: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) * 0.5), y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: image.size))
}
return
}
if let url = Bundle.main.url(forResource: "profile_level\(levelIndex)_inner", withExtension: "svg"), let data = try? Data(contentsOf: url) {
if let image = generateTintedImage(image: drawSvgImage(data, size, nil, nil, 0.0, false), color: component.backgroundColor) {
image.draw(in: CGRect(origin: CGPoint(x: 0.0, y: backgroundOffsetsY[levelIndex] ?? 0.0), size: size), blendMode: .normal, alpha: 1.0)
}
}
if component.foregroundColor.alpha < 1.0 {
context.setBlendMode(.copy)
} else {
context.setBlendMode(.normal)
}
if let textLayout {
let titleScale: CGFloat
if level < 0 {
if abs(level) < 10 {
titleScale = 0.8
} else if abs(level) < 100 {
titleScale = 0.6
} else {
titleScale = 0.4
}
} else if level < 10 {
titleScale = 1.0
} else if level < 100 {
titleScale = 0.8
} else {
titleScale = 0.6
}
let textFrame = CGRect(origin: CGPoint(x: (size.width - textLayout.size.width) * 0.5, y: (size.height - textLayout.size.height) * 0.5), size: textLayout.size)
context.saveGState()
context.translateBy(x: textFrame.midX, y: textFrame.midY)
context.scaleBy(x: titleScale, y: titleScale)
context.translateBy(x: -textFrame.midX, y: -textFrame.midY)
var drawPoint: CGPoint
drawPoint = textFrame.origin
if level >= 1 && level <= 99 {
let numberOffset = numberOffsets[level - 1]
drawPoint.x += numberOffset.x
drawPoint.y += numberOffset.y
} else {
drawPoint.x += -UIScreenPixel + -textLayout.opticalBounds.minX + (textFrame.width - textLayout.opticalBounds.width) * 0.5
}
attributedText.draw(at: drawPoint)
context.restoreGState()
}
})
if let previousContents = self.backgroundLayer.contents, CFGetTypeID(previousContents as CFTypeRef) == CGImage.typeID {
self.backgroundLayer.contents = backgroundImage!.cgImage
alphaTransition.animateContentsImage(layer: self.backgroundLayer, from: previousContents as! CGImage, to: backgroundImage!.cgImage!, duration: 0.2, curve: .easeInOut)
} else {
self.backgroundLayer.contents = backgroundImage!.cgImage
}
}
let backgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) * 0.5), y: floorToScreenPixels((size.height - iconSize.height) * 0.5)), size: iconSize)
transition.setFrame(layer: self.backgroundLayer, frame: backgroundFrame)
transition.setFrame(layer: self.borderLayer, frame: backgroundFrame)
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)
}
}
@@ -0,0 +1,175 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoScreen",
module_name = "PeerInfoScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/AccountContext",
"//submodules/AccountUtils",
"//submodules/ActionSheetPeerItem",
"//submodules/ActivityIndicator",
"//submodules/TelegramUI/Components/AnimationCache",
"//submodules/AnimationUI",
"//submodules/AppBundle",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramUI/Components/AvatarEditorScreen",
"//submodules/AvatarNode",
"//submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent",
"//submodules/AvatarVideoNode",
"//submodules/CalendarMessageScreen",
"//submodules/CallListUI",
"//submodules/TelegramUI/Components/Chat/ChatAvatarNavigationNode",
"//submodules/TelegramUI/Components/ChatControllerInteraction",
"//submodules/ChatInterfaceState",
"//submodules/TelegramUI/Components/ChatListHeaderComponent",
"//submodules/ChatListUI",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemView",
"//submodules/ChatPresentationInterfaceState",
"//submodules/TelegramUI/Components/ChatTimerScreen",
"//submodules/TelegramUI/Components/ChatTitleView",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/ComponentFlow",
"//submodules/ContextUI",
"//submodules/PeerInfoUI/CreateExternalMediaStreamScreen",
"//submodules/DeviceAccess",
"//submodules/Display",
"//submodules/TelegramUI/Components/EmojiStatusComponent",
"//submodules/TelegramUI/Components/EmojiStatusSelectionComponent",
"//submodules/EncryptionKeyVisualization",
"//submodules/TelegramUI/Components/EntityKeyboard",
"//submodules/TelegramUI/Components/ForumCreateTopicScreen",
"//submodules/GalleryData",
"//submodules/GalleryUI",
"//submodules/Geocoding",
"//submodules/HashtagSearchUI",
"//submodules/InstantPageCache",
"//submodules/InviteLinksUI",
"//submodules/ItemListAddressItem",
"//submodules/ItemListPeerActionItem",
"//submodules/ItemListPeerItem",
"//submodules/ItemListUI",
"//submodules/LegacyComponents",
"//submodules/LegacyMediaPickerUI",
"//submodules/LegacyUI",
"//submodules/ListMessageItem",
"//submodules/LocationResources",
"//submodules/LocationUI",
"//submodules/ManagedAnimationNode",
"//submodules/MapResourceToAvatarSizes",
"//submodules/Markdown",
"//submodules/MediaResources",
"//submodules/MergeLists",
"//submodules/TelegramUI/Components/MultiAnimationRenderer",
"//submodules/TelegramUI/Components/NotificationExceptionsScreen",
"//submodules/NotificationMuteSettingsUI",
"//submodules/TelegramUI/Components/NotificationPeerExceptionController",
"//submodules/NotificationSoundSelectionUI",
"//submodules/OpenInExternalAppUI",
"//submodules/OverlayStatusController",
"//submodules/PassportUI",
"//submodules/PasswordSetupUI",
"//submodules/PaymentMethodUI",
"//submodules/TelegramUI/Components/PeerAllowedReactionsScreen",
"//submodules/PeerAvatarGalleryUI",
"//submodules/PeerInfoAvatarListNode",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen",
"//submodules/PeerInfoUI",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
"//submodules/PeerPresenceStatusManager",
"//submodules/TelegramUI/Components/PeerReportScreen",
"//submodules/PhoneNumberFormat",
"//submodules/PhotoResources",
"//submodules/Postbox",
"//submodules/PremiumUI",
"//submodules/PresentationDataUtils",
"//submodules/QrCodeUI",
"//submodules/RadialStatusNode",
"//submodules/SaveToCameraRoll",
"//submodules/SearchBarNode",
"//submodules/SearchUI",
"//submodules/TelegramUI/Components/SendInviteLinkScreen",
"//submodules/SettingsUI",
"//submodules/ShareController",
"//submodules/TelegramUI/Components/ShareWithPeersScreen",
"//submodules/StatisticsUI",
"//submodules/StickerPackPreviewUI",
"//submodules/StickerResources",
"//submodules/TelegramUI/Components/StorageUsageScreen",
"//submodules/TelegramUI/Components/Stories/StoryContainerScreen",
"//submodules/SSignalKit/SSignalKit",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramBaseController",
"//submodules/TelegramCallsUI",
"//submodules/TelegramCore",
"//submodules/TelegramIntents",
"//submodules/TelegramNotices",
"//submodules/TelegramPresentationData",
"//submodules/TelegramStringFormatting",
"//submodules/TelegramUIPreferences",
"//submodules/TelegramUniversalVideoContent",
"//submodules/TelegramVoip",
"//submodules/TemporaryCachedPeerDataManager",
"//submodules/TextFormat",
"//submodules/TooltipUI",
"//submodules/TranslateUI",
"//submodules/UndoUI",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/WebSearchUI",
"//submodules/WebUI",
"//submodules/TelegramUI/Components/MultiScaleTextNode",
"//submodules/GridMessageSelectionNode",
"//submodules/ChatMessageInteractiveMediaBadge",
"//submodules/SoftwareVideo",
"//submodules/TelegramUI/Components/Chat/ChatMessageSelectionInputPanelNode",
"//submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoCoverComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/SolidRoundedButtonNode",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoPaneNode",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode",
"//submodules/MediaPickerUI",
"//submodules/AttachmentUI",
"//submodules/TelegramUI/Components/Settings/BoostLevelIconComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/Settings/PeerNameColorItem",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUI/Components/Settings/BirthdayPickerScreen",
"//submodules/TelegramUI/Components/Settings/PeerSelectionScreen",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/ConfettiEffect",
"//submodules/ContactsPeerItem",
"//submodules/TelegramUI/Components/PeerManagement/OldChannelsController",
"//submodules/TelegramUI/Components/TextNodeWithEntities",
"//submodules/UrlHandling",
"//submodules/Pasteboard",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/TelegramUI/Components/MediaEditor",
"//submodules/TelegramUI/Components/MediaEditorScreen",
"//submodules/TelegramUI/Components/CameraScreen",
"//submodules/TelegramUI/Components/PeerInfo/VerifyAlertController",
"//submodules/TelegramUI/Components/Gifts/GiftViewScreen",
"//submodules/TelegramUI/Components/BatchVideoRendering",
"//submodules/TelegramUI/Components/GifVideoLayer",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent",
"//submodules/TelegramUI/Components/PeerInfo/ProfileLevelInfoScreen",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/BottomButtonPanelComponent",
"//submodules/TelegramUI/Components/MarqueeComponent",
"//submodules/TelegramUI/Components/MediaManager/PeerMessagesMediaPlaylist",
"//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TelegramUI/Components/EdgeEffect",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,133 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import AnimationUI
import Display
final class DynamicIslandMaskNode: ASDisplayNode {
var animationNode: AnimationNode?
var isForum = false {
didSet {
if self.isForum != oldValue {
self.animationNode?.removeFromSupernode()
let animationNode = AnimationNode(animation: "ForumAvatarMask")
self.addSubnode(animationNode)
self.animationNode = animationNode
}
}
}
override init() {
let animationNode = AnimationNode(animation: "UserAvatarMask")
self.animationNode = animationNode
super.init()
self.addSubnode(animationNode)
}
func update(_ value: CGFloat) {
self.animationNode?.setProgress(value)
}
var animating = false
override func layout() {
self.animationNode?.frame = self.bounds
}
}
final class DynamicIslandBlurNode: ASDisplayNode {
private var effectView: UIVisualEffectView?
private let fadeNode = ASDisplayNode()
let gradientNode = ASImageNode()
private var hierarchyTrackingNode: HierarchyTrackingNode?
deinit {
self.animator?.stopAnimation(true)
}
override func didLoad() {
super.didLoad()
let hierarchyTrackingNode = HierarchyTrackingNode({ [weak self] value in
if !value {
self?.animator?.stopAnimation(true)
self?.animator = nil
}
})
self.hierarchyTrackingNode = hierarchyTrackingNode
self.addSubnode(hierarchyTrackingNode)
self.fadeNode.backgroundColor = .black
self.fadeNode.alpha = 0.0
self.gradientNode.displaysAsynchronously = false
let gradientImage = generateImage(CGSize(width: 100.0, height: 100.0), rotatedContext: { size, context in
let bounds = CGRect(origin: .zero, size: size)
context.clear(bounds)
var locations: [CGFloat] = [0.0, 0.87, 1.0]
let colors: [CGColor] = [UIColor(rgb: 0x000000, alpha: 0.0).cgColor, UIColor(rgb: 0x000000, alpha: 0.0).cgColor, UIColor(rgb: 0x000000, alpha: 1.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
let endRadius: CGFloat = 90.0
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0 + 38.0)
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: endRadius, options: .drawsAfterEndLocation)
})
self.gradientNode.image = gradientImage
let effectView = UIVisualEffectView(effect: nil)
self.effectView = effectView
self.view.insertSubview(effectView, at: 0)
self.addSubnode(self.gradientNode)
self.addSubnode(self.fadeNode)
}
private var animator: UIViewPropertyAnimator?
func prepare() -> Bool {
guard self.animator == nil else {
return false
}
let animator = UIViewPropertyAnimator(duration: 1.0, curve: .linear)
self.animator = animator
self.effectView?.effect = nil
animator.addAnimations { [weak self] in
self?.effectView?.effect = UIBlurEffect(style: .dark)
}
return true
}
func update(_ value: CGFloat) {
let fadeAlpha = min(1.0, max(0.0, -0.25 + value * 1.55))
if value > 0.0 {
var value = value
let updated = self.prepare()
if value > 0.99 && updated {
value = 0.99
}
self.animator?.fractionComplete = max(0.0, -0.1 + value * 1.1)
} else {
self.animator?.stopAnimation(true)
self.animator = nil
self.effectView?.effect = nil
}
self.fadeNode.alpha = fadeAlpha
}
override func layout() {
super.layout()
self.effectView?.frame = self.bounds
self.fadeNode.frame = self.bounds
let gradientSize = CGSize(width: 100.0, height: 100.0)
self.gradientNode.frame = CGRect(origin: CGPoint(x: (self.bounds.width - gradientSize.width) / 2.0, y: 0.0), size: gradientSize)
}
}
@@ -0,0 +1,185 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import AvatarNode
import AccountContext
enum PeerInfoScreenActionColor {
case accent
case destructive
}
enum PeerInfoScreenActionAligmnent {
case natural
case center
case peerList
}
final class PeerInfoScreenActionItem: PeerInfoScreenItem {
let id: AnyHashable
let text: String
let color: PeerInfoScreenActionColor
let icon: UIImage?
let iconSignal: Signal<UIImage?, NoError>?
let alignment: PeerInfoScreenActionAligmnent
let action: (() -> Void)?
init(id: AnyHashable, text: String, color: PeerInfoScreenActionColor = .accent, icon: UIImage? = nil, iconSignal: Signal<UIImage?, NoError>? = nil, alignment: PeerInfoScreenActionAligmnent = .natural, action: (() -> Void)?) {
self.id = id
self.text = text
self.color = color
self.icon = icon
self.iconSignal = iconSignal
self.alignment = alignment
self.action = action
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenActionItemNode()
}
}
private final class PeerInfoScreenActionItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let textNode: ImmediateTextNode
private let bottomSeparatorNode: ASDisplayNode
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenActionItem?
private let iconDisposable = MetaDisposable()
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displaysAsynchronously = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.addSubnode(self.maskNode)
self.addSubnode(self.textNode)
self.addSubnode(self.activateArea)
}
deinit {
self.iconDisposable.dispose()
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenActionItem else {
return 10.0
}
self.item = item
self.selectionNode.pressed = item.action
let sideInset: CGFloat = 16.0 + safeInsets.left
var leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0)
var iconInset = sideInset
if case .peerList = item.alignment {
leftInset += 5.0
iconInset += 5.0
}
let rightInset = sideInset
let separatorInset = item.icon == nil ? sideInset : leftInset - 1.0
let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize)
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let textColorValue: UIColor
switch item.color {
case .accent:
textColorValue = presentationData.theme.list.itemAccentColor
case .destructive:
textColorValue = presentationData.theme.list.itemDestructiveColor
}
self.textNode.maximumNumberOfLines = 1
self.textNode.attributedText = NSAttributedString(string: item.text, font: titleFont, textColor: textColorValue)
self.activateArea.accessibilityLabel = item.text
let textSize = self.textNode.updateLayout(CGSize(width: width - (leftInset + rightInset), height: .greatestFiniteMagnitude))
let height = textSize.height + 32.0
let textFrame = CGRect(origin: CGPoint(x: item.alignment == .center ? floorToScreenPixels((width - textSize.width) / 2.0) : leftInset, y: floorToScreenPixels((height - textSize.height) / 2.0)), size: textSize)
if let icon = item.icon {
if self.iconNode.supernode == nil {
self.addSubnode(self.iconNode)
}
self.iconNode.image = generateTintedImage(image: icon, color: textColorValue)
let iconFrame = CGRect(origin: CGPoint(x: iconInset, y: floorToScreenPixels((height - icon.size.height) / 2.0)), size: icon.size)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
} else if let iconSignal = item.iconSignal {
self.iconDisposable.set((iconSignal
|> deliverOnMainQueue).startStrict(next: { [weak self] image in
if let strongSelf = self, let image {
strongSelf.iconNode.image = image
let iconFrame = CGRect(origin: CGPoint(x: iconInset, y: floorToScreenPixels((height - image.size.height) / 2.0)), size: image.size)
transition.updateFrame(node: strongSelf.iconNode, frame: iconFrame)
}
}))
if self.iconNode.supernode == nil {
self.addSubnode(self.iconNode)
}
} else if self.iconNode.supernode != nil {
self.iconNode.image = nil
self.iconNode.removeFromSupernode()
}
if self.textNode.frame.width != textFrame.width {
self.textNode.frame = textFrame
} else {
transition.updateFrame(node: self.textNode, frame: textFrame)
}
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
let separatorRightInset: CGFloat = 16.0
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: height - UIScreenPixel), size: CGSize(width: width - separatorInset - separatorRightInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
return height
}
}
@@ -0,0 +1,257 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListAddressItem
import SwiftSignalKit
import AccountContext
final class PeerInfoScreenAddressItem: PeerInfoScreenItem {
let id: AnyHashable
let label: String
let text: String
let imageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
let action: (() -> Void)?
let longTapAction: ((ASDisplayNode, String) -> Void)?
let linkItemAction: ((TextLinkItemActionType, TextLinkItem) -> Void)?
let contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
init(
id: AnyHashable,
label: String,
text: String,
imageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?,
action: (() -> Void)?,
longTapAction: ((ASDisplayNode, String) -> Void)? = nil,
linkItemAction: ((TextLinkItemActionType, TextLinkItem) -> Void)? = nil,
contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)? = nil
) {
self.id = id
self.label = label
self.text = text
self.imageSignal = imageSignal
self.action = action
self.longTapAction = longTapAction
self.linkItemAction = linkItemAction
self.contextAction = contextAction
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenAddressItemNode()
}
}
private final class PeerInfoScreenAddressItemNode: PeerInfoScreenItemNode {
private let containerNode: ContextControllerSourceNode
private let contextSourceNode: ContextExtractedContentContainingNode
private let extractedBackgroundImageNode: ASImageNode
private var extractedRect: CGRect?
private var nonExtractedRect: CGRect?
private let maskNode: ASImageNode
private let bottomSeparatorNode: ASDisplayNode
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenAddressItem?
private var itemNode: ItemListAddressItemNode?
private var presentationData: PresentationData?
override init() {
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.extractedBackgroundImageNode = ASImageNode()
self.extractedBackgroundImageNode.displaysAsynchronously = false
self.extractedBackgroundImageNode.alpha = 0.0
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
self.addSubnode(self.bottomSeparatorNode)
self.containerNode.addSubnode(self.contextSourceNode)
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
self.addSubnode(self.containerNode)
self.addSubnode(self.maskNode)
self.contextSourceNode.contentNode.clipsToBounds = true
self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode)
self.addSubnode(self.activateArea)
self.containerNode.isGestureEnabled = false
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction else {
gesture.cancel()
return
}
contextAction(strongSelf.contextSourceNode, gesture, nil)
}
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let strongSelf = self, let presentationData = strongSelf.presentationData else {
return
}
let theme = presentationData.theme
if isExtracted {
strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 52.0, color: theme.list.plainBackgroundColor)
}
if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect {
let rect = isExtracted ? extractedRect : nonExtractedRect
transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect)
}
transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
if !isExtracted {
self?.extractedBackgroundImageNode.image = nil
}
})
}
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { [weak self] _ in
guard let self, let item = self.item else {
return .keepWithSingleTap
}
if item.longTapAction != nil {
return .waitForSingleTap
}
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.updateTouchesAtPoint(point)
}
self.view.addGestureRecognizer(recognizer)
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
if let item = self.item {
item.action?()
}
case .longTap:
if let item = self.item {
item.longTapAction?(self, item.text)
}
default:
break
}
}
default:
break
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenAddressItem else {
return 10.0
}
self.item = item
self.presentationData = presentationData
self.containerNode.isGestureEnabled = item.contextAction != nil
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let addressItem = ItemListAddressItem(theme: presentationData.theme, systemStyle: .glass, label: item.label, text: item.text, imageSignal: item.imageSignal, sectionId: 0, style: .blocks, displayDecorations: false, action: nil, longTapAction: nil, linkItemAction: item.linkItemAction)
let params = ListViewItemLayoutParams(width: width, leftInset: safeInsets.left, rightInset: safeInsets.right, availableHeight: 1000.0)
let itemNode: ItemListAddressItemNode
if let current = self.itemNode {
itemNode = current
addressItem.updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: addressItem, nextItem: addressItem, animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var itemNodeValue: ListViewItemNode?
addressItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { node, apply in
itemNodeValue = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode = itemNodeValue as! ItemListAddressItemNode
itemNode.isUserInteractionEnabled = false
self.itemNode = itemNode
self.contextSourceNode.contentNode.addSubnode(itemNode)
}
let height = itemNode.contentSize.height
let separatorRightInset: CGFloat = 16.0
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset - separatorRightInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
transition.updateFrame(node: self.maskNode, frame: CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height)))
self.bottomSeparatorNode.isHidden = hasBottomCorners
self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height))
self.activateArea.accessibilityLabel = item.label
let contentSize = CGSize(width: width, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: contentSize)
transition.updateFrame(node: self.contextSourceNode.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize))
let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: contentSize.height))
let extractedRect = nonExtractedRect
self.extractedRect = extractedRect
self.nonExtractedRect = nonExtractedRect
if self.contextSourceNode.isExtractedToContextPreview {
self.extractedBackgroundImageNode.frame = extractedRect
} else {
self.extractedBackgroundImageNode.frame = nonExtractedRect
}
self.contextSourceNode.contentRect = extractedRect
return height
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
}
}
@@ -0,0 +1,101 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TelegramCore
import AccountContext
import ComponentFlow
import BirthdayPickerScreen
final class PeerInfoScreenBirthdatePickerItem: PeerInfoScreenItem {
let id: AnyHashable
let value: TelegramBirthday
let valueUpdated: (TelegramBirthday) -> Void
init(
id: AnyHashable,
value: TelegramBirthday,
valueUpdated: @escaping (TelegramBirthday) -> Void
) {
self.id = id
self.value = value
self.valueUpdated = valueUpdated
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenBirthdatePickerItemNode()
}
}
private final class PeerInfoScreenBirthdatePickerItemNode: PeerInfoScreenItemNode {
private let maskNode: ASImageNode
private let picker = ComponentView<Empty>()
private let bottomSeparatorNode: ASDisplayNode
private var item: PeerInfoScreenBirthdatePickerItem?
private var presentationData: PresentationData?
private var theme: PresentationTheme?
override init() {
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
super.init()
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.maskNode)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenBirthdatePickerItem else {
return 10.0
}
self.item = item
self.presentationData = presentationData
self.theme = presentationData.theme
let sideInset: CGFloat = 16.0 + safeInsets.left
let height: CGFloat = 226.0
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let pickerSize = self.picker.update(
transition: .immediate,
component: AnyComponent(BirthdayPickerComponent(
theme: BirthdayPickerComponent.Theme(presentationTheme: presentationData.theme),
strings: presentationData.strings,
value: item.value,
valueUpdated: item.valueUpdated
)),
environment: {},
containerSize: CGSize(width: width - sideInset * 2.0, height: height)
)
let pickerFrame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: pickerSize)
if let pickerView = self.picker.view {
if pickerView.superview == nil {
self.view.addSubview(pickerView)
}
transition.updateFrame(view: pickerView, frame: pickerFrame)
}
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
return height
}
}
@@ -0,0 +1,677 @@
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AccountContext
import TextFormat
import UIKit
import AppBundle
import TelegramStringFormatting
import ContextUI
import TelegramCore
import ComponentFlow
import MultilineTextComponent
import BundleIconComponent
import PlainButtonComponent
import AccountContext
func businessHoursTextToCopy(businessHours: TelegramBusinessHours, presentationData: PresentationData, displayLocalTimezone: Bool) -> String {
var text = ""
let cachedDays = businessHours.splitIntoWeekDays()
var timezoneOffsetMinutes: Int = 0
if displayLocalTimezone {
var currentCalendar = Calendar(identifier: .gregorian)
currentCalendar.timeZone = TimeZone(identifier: businessHours.timezoneId) ?? TimeZone.current
let currentTimezone = TimeZone.current
timezoneOffsetMinutes = (currentTimezone.secondsFromGMT() - currentCalendar.timeZone.secondsFromGMT()) / 60
}
let businessDays: [TelegramBusinessHours.WeekDay] = cachedDays
for i in 0 ..< businessDays.count {
let dayTitleValue: String
switch i {
case 0:
dayTitleValue = presentationData.strings.Weekday_Monday
case 1:
dayTitleValue = presentationData.strings.Weekday_Tuesday
case 2:
dayTitleValue = presentationData.strings.Weekday_Wednesday
case 3:
dayTitleValue = presentationData.strings.Weekday_Thursday
case 4:
dayTitleValue = presentationData.strings.Weekday_Friday
case 5:
dayTitleValue = presentationData.strings.Weekday_Saturday
case 6:
dayTitleValue = presentationData.strings.Weekday_Sunday
default:
dayTitleValue = " "
}
let businessHoursText = dayBusinessHoursText(presentationData: presentationData, day: businessDays[i], offsetMinutes: timezoneOffsetMinutes, formatAsPlainText: true)
if !text.isEmpty {
text.append("\n")
}
text.append("\(dayTitleValue): \(businessHoursText)")
}
return text
}
private func dayBusinessHoursText(presentationData: PresentationData, day: TelegramBusinessHours.WeekDay, offsetMinutes: Int, formatAsPlainText: Bool = false) -> String {
var businessHoursText: String = ""
switch day {
case .open:
businessHoursText += presentationData.strings.PeerInfo_BusinessHours_DayOpen24h
case .closed:
businessHoursText += presentationData.strings.PeerInfo_BusinessHours_DayClosed
case let .intervals(intervals):
func clipMinutes(_ value: Int) -> Int {
var value = value
if value < 0 {
value = 24 * 60 + value
}
return value % (24 * 60)
}
var resultText: String = ""
for range in intervals {
let range = TelegramBusinessHours.WorkingTimeInterval(startMinute: range.startMinute + offsetMinutes, endMinute: range.endMinute + offsetMinutes)
if !resultText.isEmpty {
if formatAsPlainText {
resultText.append(", ")
} else {
resultText.append("\n")
}
}
let startHours = clipMinutes(range.startMinute) / 60
let startMinutes = clipMinutes(range.startMinute) % 60
let startText = stringForShortTimestamp(hours: Int32(startHours), minutes: Int32(startMinutes), dateTimeFormat: presentationData.dateTimeFormat, formatAsPlainText: formatAsPlainText)
let endHours = clipMinutes(range.endMinute) / 60
let endMinutes = clipMinutes(range.endMinute) % 60
let endText = stringForShortTimestamp(hours: Int32(endHours), minutes: Int32(endMinutes), dateTimeFormat: presentationData.dateTimeFormat, formatAsPlainText: formatAsPlainText)
resultText.append("\(startText) - \(endText)")
}
businessHoursText += resultText
}
return businessHoursText
}
final class PeerInfoScreenBusinessHoursItem: PeerInfoScreenItem {
let id: AnyHashable
let label: String
let businessHours: TelegramBusinessHours
let requestLayout: (Bool) -> Void
let longTapAction: ((ASDisplayNode, String) -> Void)?
let contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
init(
id: AnyHashable,
label: String,
businessHours: TelegramBusinessHours,
requestLayout: @escaping (Bool) -> Void,
longTapAction: ((ASDisplayNode, String) -> Void)? = nil,
contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)? = nil
) {
self.id = id
self.label = label
self.businessHours = businessHours
self.requestLayout = requestLayout
self.longTapAction = longTapAction
self.contextAction = contextAction
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenBusinessHoursItemNode()
}
}
private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode {
private let containerNode: ContextControllerSourceNode
private let contextSourceNode: ContextExtractedContentContainingNode
private let extractedBackgroundImageNode: ASImageNode
private var extractedRect: CGRect?
private var nonExtractedRect: CGRect?
private let maskNode: ASImageNode
private let labelNode: ImmediateTextNode
private let currentStatusText = ComponentView<Empty>()
private let currentDayText = ComponentView<Empty>()
private var timezoneSwitchButton: ComponentView<Empty>?
private var dayTitles: [ComponentView<Empty>] = []
private var dayValues: [ComponentView<Empty>] = []
private let arrowIcon = ComponentView<Empty>()
private let bottomSeparatorNode: ASDisplayNode
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenBusinessHoursItem?
private var presentationData: PresentationData?
private var theme: PresentationTheme?
private var currentTimezone: TimeZone
private var displayLocalTimezone: Bool = false
private var cachedDays: [TelegramBusinessHours.WeekDay] = []
private var cachedWeekMinuteSet = IndexSet()
private var isExpanded: Bool = false
override init() {
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.extractedBackgroundImageNode = ASImageNode()
self.extractedBackgroundImageNode.displaysAsynchronously = false
self.extractedBackgroundImageNode.alpha = 0.0
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.labelNode = ImmediateTextNode()
self.labelNode.displaysAsynchronously = false
self.labelNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
self.currentTimezone = TimeZone.current
super.init()
self.addSubnode(self.bottomSeparatorNode)
self.containerNode.addSubnode(self.contextSourceNode)
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
self.addSubnode(self.containerNode)
self.addSubnode(self.maskNode)
self.contextSourceNode.contentNode.clipsToBounds = true
self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode)
self.contextSourceNode.contentNode.addSubnode(self.labelNode)
self.addSubnode(self.activateArea)
self.containerNode.isGestureEnabled = false
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction else {
gesture.cancel()
return
}
contextAction(strongSelf.contextSourceNode, gesture, nil)
}
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let strongSelf = self, let theme = strongSelf.theme else {
return
}
if isExtracted {
strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 52.0, color: theme.list.plainBackgroundColor)
}
if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect {
let rect = isExtracted ? extractedRect : nonExtractedRect
transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect)
}
transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
if !isExtracted {
self?.extractedBackgroundImageNode.image = nil
}
})
}
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { [weak self] _ in
guard let self, let item = self.item else {
return .keepWithSingleTap
}
if item.longTapAction != nil {
return .waitForSingleTap
}
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.updateTouchesAtPoint(point)
}
self.view.addGestureRecognizer(recognizer)
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
self.isExpanded = !self.isExpanded
self.item?.requestLayout(true)
case .longTap:
if let item = self.item, let presentationData = self.presentationData {
item.longTapAction?(self, businessHoursTextToCopy(businessHours: item.businessHours, presentationData: presentationData, displayLocalTimezone: self.displayLocalTimezone))
}
default:
break
}
}
default:
break
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenBusinessHoursItem else {
return 10.0
}
let businessDays: [TelegramBusinessHours.WeekDay]
if self.item?.businessHours != item.businessHours {
businessDays = item.businessHours.splitIntoWeekDays()
self.cachedDays = businessDays
self.cachedWeekMinuteSet = item.businessHours.weekMinuteSet()
} else {
businessDays = self.cachedDays
}
self.item = item
self.presentationData = presentationData
self.theme = presentationData.theme
self.containerNode.isGestureEnabled = item.contextAction != nil
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
self.labelNode.attributedText = NSAttributedString(string: item.label, font: Font.regular(14.0), textColor: presentationData.theme.list.itemPrimaryTextColor)
let labelSize = self.labelNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
var topOffset = 10.0
let labelFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset), size: labelSize)
if labelSize.height > 0.0 {
topOffset += labelSize.height
topOffset += 3.0
}
let arrowIconSize = self.arrowIcon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: "Item List/DownArrow",
tintColor: presentationData.theme.list.disclosureArrowColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let arrowIconFrame = CGRect(origin: CGPoint(x: width - sideInset + 1.0 - arrowIconSize.width, y: topOffset + 5.0), size: arrowIconSize)
if let arrowIconView = self.arrowIcon.view {
if arrowIconView.superview == nil {
self.contextSourceNode.contentNode.view.addSubview(arrowIconView)
arrowIconView.frame = arrowIconFrame
}
transition.updatePosition(layer: arrowIconView.layer, position: arrowIconFrame.center)
transition.updateBounds(layer: arrowIconView.layer, bounds: CGRect(origin: CGPoint(), size: arrowIconFrame.size))
transition.updateTransformRotation(view: arrowIconView, angle: self.isExpanded ? CGFloat.pi * 1.0 : CGFloat.pi * 0.0)
}
var currentCalendar = Calendar(identifier: .gregorian)
currentCalendar.timeZone = TimeZone(identifier: item.businessHours.timezoneId) ?? TimeZone.current
let currentDate = Date()
var currentDayIndex = currentCalendar.component(.weekday, from: currentDate)
if currentDayIndex == 1 {
currentDayIndex = 6
} else {
currentDayIndex -= 2
}
let currentMinute = currentCalendar.component(.minute, from: currentDate)
let currentHour = currentCalendar.component(.hour, from: currentDate)
let currentWeekMinute = currentDayIndex * 24 * 60 + currentHour * 60 + currentMinute
var timezoneOffsetMinutes: Int = 0
if self.displayLocalTimezone {
timezoneOffsetMinutes = (self.currentTimezone.secondsFromGMT() - currentCalendar.timeZone.secondsFromGMT()) / 60
}
let isOpen = self.cachedWeekMinuteSet.contains(currentWeekMinute)
let openStatusText = isOpen ? presentationData.strings.PeerInfo_BusinessHours_StatusOpen : presentationData.strings.PeerInfo_BusinessHours_StatusClosed
var currentDayStatusText = currentDayIndex >= 0 && currentDayIndex < businessDays.count ? dayBusinessHoursText(presentationData: presentationData, day: businessDays[currentDayIndex], offsetMinutes: timezoneOffsetMinutes) : " "
if !isOpen {
for range in self.cachedWeekMinuteSet.rangeView {
if range.lowerBound > currentWeekMinute {
let openInMinutes = range.lowerBound - currentWeekMinute
if openInMinutes < 60 {
currentDayStatusText = presentationData.strings.PeerInfo_BusinessHours_StatusOpensInMinutes(Int32(openInMinutes))
} else if openInMinutes < 6 * 60 {
currentDayStatusText = presentationData.strings.PeerInfo_BusinessHours_StatusOpensInHours(Int32(openInMinutes / 60))
} else {
let openDate = currentDate.addingTimeInterval(Double(openInMinutes * 60))
let openTimestamp = Int32(openDate.timeIntervalSince1970) + Int32(currentCalendar.timeZone.secondsFromGMT() - TimeZone.current.secondsFromGMT())
let dateText = humanReadableStringForTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, timestamp: openTimestamp, alwaysShowTime: true, allowYesterday: false, format: HumanReadableStringFormat(
dateFormatString: { value in
let text = PresentationStrings.FormattedString(string: presentationData.strings.Chat_MessageSeenTimestamp_Date(value).string, ranges: [])
return presentationData.strings.PeerInfo_BusinessHours_StatusOpensOnDate(text.string)
},
tomorrowFormatString: { value in
return PresentationStrings.FormattedString(string: presentationData.strings.PeerInfo_BusinessHours_StatusOpensTomorrowAt(value).string, ranges: [])
},
todayFormatString: { value in
return PresentationStrings.FormattedString(string: presentationData.strings.PeerInfo_BusinessHours_StatusOpensTodayAt(value).string, ranges: [])
},
yesterdayFormatString: { value in
return PresentationStrings.FormattedString(string: presentationData.strings.PeerInfo_BusinessHours_StatusOpensTodayAt(value).string, ranges: [])
},
daysFormatString: { value in
return PresentationStrings.FormattedString(string: presentationData.strings.PeerInfo_BusinessHours_StatusOpensInDays(Int32(value)), ranges: [])
}
)).string
currentDayStatusText = dateText
}
break
}
}
}
let currentStatusTextSize = self.currentStatusText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: openStatusText, font: Font.regular(15.0), textColor: isOpen ? presentationData.theme.list.freeTextSuccessColor : presentationData.theme.list.itemDestructiveColor))
)),
environment: {},
containerSize: CGSize(width: width - sideInset * 2.0, height: 100.0)
)
let currentStatusTextFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset), size: currentStatusTextSize)
if let currentStatusTextView = self.currentStatusText.view {
if currentStatusTextView.superview == nil {
currentStatusTextView.layer.anchorPoint = CGPoint()
self.contextSourceNode.contentNode.view.addSubview(currentStatusTextView)
}
transition.updatePosition(layer: currentStatusTextView.layer, position: currentStatusTextFrame.origin)
currentStatusTextView.bounds = CGRect(origin: CGPoint(), size: currentStatusTextFrame.size)
}
let dayRightInset = sideInset + 17.0
let currentDayTextSize = self.currentDayText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: currentDayStatusText, font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor)),
horizontalAlignment: .right,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: width - sideInset - dayRightInset, height: 100.0)
)
var hasTimezoneDependentEntries = false
if item.businessHours.timezoneId != self.currentTimezone.identifier {
var currentCalendar = Calendar(identifier: .gregorian)
currentCalendar.timeZone = TimeZone(identifier: item.businessHours.timezoneId) ?? TimeZone.current
let timezoneOffsetMinutes = (self.currentTimezone.secondsFromGMT() - currentCalendar.timeZone.secondsFromGMT()) / 60
for i in 0 ..< businessDays.count {
let businessHoursTextLocal = dayBusinessHoursText(presentationData: presentationData, day: businessDays[i], offsetMinutes: 0)
let businessHoursTextOffset = dayBusinessHoursText(presentationData: presentationData, day: businessDays[i], offsetMinutes: timezoneOffsetMinutes)
if businessHoursTextOffset != businessHoursTextLocal {
hasTimezoneDependentEntries = true
break
}
}
}
var timezoneSwitchButtonSize: CGSize?
if hasTimezoneDependentEntries {
let timezoneSwitchButton: ComponentView<Empty>
if let current = self.timezoneSwitchButton {
timezoneSwitchButton = current
} else {
timezoneSwitchButton = ComponentView()
self.timezoneSwitchButton = timezoneSwitchButton
}
let timezoneSwitchTitle: String
if self.displayLocalTimezone {
timezoneSwitchTitle = presentationData.strings.PeerInfo_BusinessHours_TimezoneSwitchMy
} else {
timezoneSwitchTitle = presentationData.strings.PeerInfo_BusinessHours_TimezoneSwitchBusiness
}
timezoneSwitchButtonSize = timezoneSwitchButton.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: timezoneSwitchTitle, font: Font.regular(12.0), textColor: presentationData.theme.list.itemAccentColor))
)),
background: AnyComponent(RoundedRectangle(
color: presentationData.theme.list.itemAccentColor.withMultipliedAlpha(0.1),
cornerRadius: nil
)),
effectAlignment: .center,
contentInsets: UIEdgeInsets(top: 1.0, left: 7.0, bottom: 2.0, right: 7.0),
action: { [weak self] in
guard let self else {
return
}
self.displayLocalTimezone = !self.displayLocalTimezone
self.item?.requestLayout(false)
if !self.isExpanded {
self.isExpanded = true
self.item?.requestLayout(true)
}
},
animateAlpha: true,
animateScale: false,
animateContents: false
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
} else {
if let timezoneSwitchButton = self.timezoneSwitchButton {
self.timezoneSwitchButton = nil
timezoneSwitchButton.view?.removeFromSuperview()
}
}
let timezoneSwitchButtonSpacing: CGFloat = 3.0
if timezoneSwitchButtonSize != nil {
topOffset -= 20.0
}
let currentDayTextFrame = CGRect(origin: CGPoint(x: width - dayRightInset - currentDayTextSize.width, y: topOffset), size: currentDayTextSize)
if let currentDayTextView = self.currentDayText.view {
if currentDayTextView.superview == nil {
currentDayTextView.layer.anchorPoint = CGPoint()
self.contextSourceNode.contentNode.view.addSubview(currentDayTextView)
}
transition.updatePosition(layer: currentDayTextView.layer, position: currentDayTextFrame.origin)
currentDayTextView.bounds = CGRect(origin: CGPoint(), size: currentDayTextFrame.size)
}
topOffset += max(currentStatusTextSize.height, currentDayTextSize.height)
if let timezoneSwitchButtonView = self.timezoneSwitchButton?.view, let timezoneSwitchButtonSize {
topOffset += timezoneSwitchButtonSpacing
var timezoneSwitchButtonTransition = transition
if timezoneSwitchButtonView.superview == nil {
timezoneSwitchButtonTransition = .immediate
self.contextSourceNode.contentNode.view.addSubview(timezoneSwitchButtonView)
}
let timezoneSwitchButtonFrame = CGRect(origin: CGPoint(x: width - dayRightInset - timezoneSwitchButtonSize.width, y: topOffset), size: timezoneSwitchButtonSize)
timezoneSwitchButtonTransition.updateFrame(view: timezoneSwitchButtonView, frame: timezoneSwitchButtonFrame)
topOffset += timezoneSwitchButtonSize.height
}
let daySpacing: CGFloat = 15.0
var dayHeights: CGFloat = 0.0
for rawI in 0 ..< businessDays.count {
if rawI == 0 {
//skip current day
continue
}
let i = (rawI + currentDayIndex) % businessDays.count
dayHeights += daySpacing
var dayTransition = transition
let dayTitle: ComponentView<Empty>
if self.dayTitles.count > i {
dayTitle = self.dayTitles[i]
} else {
dayTransition = .immediate
dayTitle = ComponentView()
self.dayTitles.append(dayTitle)
}
let dayValue: ComponentView<Empty>
if self.dayValues.count > i {
dayValue = self.dayValues[i]
} else {
dayValue = ComponentView()
self.dayValues.append(dayValue)
}
let dayTitleValue: String
switch i {
case 0:
dayTitleValue = presentationData.strings.Weekday_Monday
case 1:
dayTitleValue = presentationData.strings.Weekday_Tuesday
case 2:
dayTitleValue = presentationData.strings.Weekday_Wednesday
case 3:
dayTitleValue = presentationData.strings.Weekday_Thursday
case 4:
dayTitleValue = presentationData.strings.Weekday_Friday
case 5:
dayTitleValue = presentationData.strings.Weekday_Saturday
case 6:
dayTitleValue = presentationData.strings.Weekday_Sunday
default:
dayTitleValue = " "
}
let businessHoursText = dayBusinessHoursText(presentationData: presentationData, day: businessDays[i], offsetMinutes: timezoneOffsetMinutes)
let dayTitleSize = dayTitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: dayTitleValue, font: Font.regular(15.0), textColor: presentationData.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: width - sideInset * 2.0, height: 100.0)
)
let dayTitleFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset + dayHeights), size: dayTitleSize)
if let dayTitleView = dayTitle.view {
if dayTitleView.superview == nil {
dayTitleView.layer.anchorPoint = CGPoint()
self.contextSourceNode.contentNode.view.addSubview(dayTitleView)
dayTitleView.alpha = 0.0
}
dayTransition.updatePosition(layer: dayTitleView.layer, position: dayTitleFrame.origin)
dayTitleView.bounds = CGRect(origin: CGPoint(), size: dayTitleFrame.size)
transition.updateAlpha(layer: dayTitleView.layer, alpha: self.isExpanded ? 1.0 : 0.0)
}
let dayValueSize = dayValue.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: businessHoursText, font: Font.regular(15.0), textColor: presentationData.theme.list.itemSecondaryTextColor, paragraphAlignment: .right)),
horizontalAlignment: .right,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: width - sideInset - dayRightInset, height: 100.0)
)
let dayValueFrame = CGRect(origin: CGPoint(x: width - dayRightInset - dayValueSize.width, y: topOffset + dayHeights), size: dayValueSize)
if let dayValueView = dayValue.view {
if dayValueView.superview == nil {
dayValueView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
self.contextSourceNode.contentNode.view.addSubview(dayValueView)
dayValueView.alpha = 0.0
}
dayTransition.updatePosition(layer: dayValueView.layer, position: CGPoint(x: dayValueFrame.maxX, y: dayValueFrame.minY))
dayValueView.bounds = CGRect(origin: CGPoint(), size: dayValueFrame.size)
transition.updateAlpha(layer: dayValueView.layer, alpha: self.isExpanded ? 1.0 : 0.0)
}
dayHeights += max(dayTitleSize.height, dayValueSize.height)
}
if self.isExpanded {
topOffset += dayHeights
}
topOffset += 11.0
transition.updateFrame(node: self.labelNode, frame: labelFrame)
let height = topOffset
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
transition.updateFrame(node: self.maskNode, frame: CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height)))
self.bottomSeparatorNode.isHidden = hasBottomCorners
self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height))
self.activateArea.accessibilityLabel = item.label
let contentSize = CGSize(width: width, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: contentSize)
transition.updateFrame(node: self.contextSourceNode.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize))
let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: contentSize.height))
let extractedRect = nonExtractedRect
self.extractedRect = extractedRect
self.nonExtractedRect = nonExtractedRect
if self.contextSourceNode.isExtractedToContextPreview {
self.extractedBackgroundImageNode.frame = extractedRect
} else {
self.extractedBackgroundImageNode.frame = nonExtractedRect
}
self.contextSourceNode.contentRect = extractedRect
return height
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
}
}
@@ -0,0 +1,125 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListAddressItem
import SwiftSignalKit
import AccountContext
import Postbox
import PeerInfoUI
import ItemListUI
final class PeerInfoScreenCallListItem: PeerInfoScreenItem {
let id: AnyHashable
let messages: [Message]
init(
id: AnyHashable,
messages: [Message]
) {
self.id = id
self.messages = messages
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenCallListItemNode()
}
}
private final class PeerInfoScreenCallListItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private var item: PeerInfoScreenCallListItem?
private var itemNode: ItemListCallListItemNode?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.selectionNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.addSubnode(self.maskNode)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenCallListItem else {
return 10.0
}
self.item = item
self.selectionNode.pressed = nil
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let addressItem = ItemListCallListItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, messages: item.messages, sectionId: 0, style: .blocks, displayDecorations: false)
let params = ListViewItemLayoutParams(width: width, leftInset: safeInsets.left, rightInset: safeInsets.right, availableHeight: 1000.0)
let itemNode: ItemListCallListItemNode
if let current = self.itemNode {
itemNode = current
addressItem.updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: nil, nextItem: nil, animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var itemNodeValue: ListViewItemNode?
addressItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { node, apply in
itemNodeValue = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode = itemNodeValue as! ItemListCallListItemNode
itemNode.isUserInteractionEnabled = false
self.itemNode = itemNode
self.addSubnode(itemNode)
}
let verticalInset: CGFloat = 8.0
let height = itemNode.contentSize.height + verticalInset * 2.0
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalInset), size: itemNode.bounds.size))
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
return height
}
}
@@ -0,0 +1,203 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TextFormat
import Markdown
import AccountContext
import TextNodeWithEntities
import TextFormat
final class PeerInfoScreenCommentItem: PeerInfoScreenItem {
enum LinkAction {
case tap(String)
}
let id: AnyHashable
let text: String
let attributedPrefix: NSAttributedString?
let useAccentLinkColor: Bool
let linkAction: ((LinkAction) -> Void)?
init(id: AnyHashable, text: String, attributedPrefix: NSAttributedString? = nil, useAccentLinkColor: Bool = true, linkAction: ((LinkAction) -> Void)? = nil) {
self.id = id
self.text = text
self.attributedPrefix = attributedPrefix
self.useAccentLinkColor = useAccentLinkColor
self.linkAction = linkAction
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenCommentItemNode()
}
}
private final class PeerInfoScreenCommentItemNode: PeerInfoScreenItemNode {
private let textNode: ImmediateTextNodeWithEntities
private var linkHighlightingNode: LinkHighlightingNode?
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenCommentItem?
private var presentationData: PresentationData?
private var chevronImage: UIImage?
override init() {
self.textNode = ImmediateTextNodeWithEntities()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.activateArea = AccessibilityAreaNode()
self.activateArea.accessibilityTraits = .staticText
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.activateArea)
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { _ in
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
if let strongSelf = self {
strongSelf.updateTouchesAtPoint(point)
}
}
self.view.addGestureRecognizer(recognizer)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenCommentItem else {
return 10.0
}
let themeUpdated = self.presentationData?.theme !== presentationData.theme
self.item = item
self.presentationData = presentationData
let sideInset: CGFloat = 16.0 + safeInsets.left
let verticalInset: CGFloat = 7.0
self.textNode.maximumNumberOfLines = 0
self.textNode.arguments = TextNodeWithEntities.Arguments(
context: context,
cache: context.animationCache,
renderer: context.animationRenderer,
placeholderColor: presentationData.theme.list.mediaPlaceholderColor,
attemptSynchronous: false
)
let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let textColor = presentationData.theme.list.freeTextColor
var text = item.text
text = text.replacingOccurrences(of: " >]", with: "\u{00A0}>]")
let attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: textFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: item.useAccentLinkColor ? presentationData.theme.list.itemAccentColor : textColor, additionalAttributes: item.useAccentLinkColor ? [:] : [NSAttributedString.Key.underlineStyle.rawValue: NSUnderlineStyle.single.rawValue as NSNumber]), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})).mutableCopy() as! NSMutableAttributedString
if let attributedPrefix = item.attributedPrefix {
attributedText.insert(attributedPrefix, at: 0)
attributedText.addAttribute(NSAttributedString.Key.font, value: textFont, range: NSRange(location: 0, length: attributedPrefix.length))
attributedText.addAttribute(NSAttributedString.Key.foregroundColor, value: textColor, range: NSRange(location: 0, length: attributedPrefix.length))
}
if let _ = item.text.range(of: ">]"), let range = attributedText.string.range(of: ">") {
if themeUpdated || self.chevronImage == nil {
self.chevronImage = generateTintedImage(image: UIImage(bundleImageName: "Contact List/SubtitleArrow"), color: presentationData.theme.list.itemAccentColor)
}
if let chevronImage = self.chevronImage {
attributedText.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: attributedText.string))
}
}
self.textNode.attributedText = attributedText
self.textNode.visibility = true
self.textNode.lineSpacing = 0.12
self.activateArea.accessibilityLabel = attributedText.string
let textSize = self.textNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: textSize)
let height = textSize.height + verticalInset * 2.0
transition.updateFrame(node: self.textNode, frame: textFrame)
self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
return height
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
let titleFrame = self.textNode.frame
if let item = self.item, titleFrame.contains(location) {
if let (_, attributes) = self.textNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) {
if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
item.linkAction?(.tap(url))
}
}
}
default:
break
}
}
default:
break
}
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
if let item = self.item, let presentationData = self.presentationData {
var rects: [CGRect]?
if let point = point {
let textNodeFrame = self.textNode.frame
if let (index, attributes) = self.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) {
let possibleNames: [String] = [
TelegramTextAttributes.URL,
TelegramTextAttributes.PeerMention,
TelegramTextAttributes.PeerTextMention,
TelegramTextAttributes.BotCommand,
TelegramTextAttributes.Hashtag
]
for name in possibleNames {
if let _ = attributes[NSAttributedString.Key(rawValue: name)] {
rects = self.textNode.attributeRects(name: name, at: index)
break
}
}
}
}
if let rects = rects {
let linkHighlightingNode: LinkHighlightingNode
if let current = self.linkHighlightingNode {
linkHighlightingNode = current
} else {
linkHighlightingNode = LinkHighlightingNode(color: item.useAccentLinkColor ? presentationData.theme.list.itemAccentColor.withAlphaComponent(0.2) : presentationData.theme.list.freeTextColor.withAlphaComponent(0.2))
self.linkHighlightingNode = linkHighlightingNode
self.insertSubnode(linkHighlightingNode, belowSubnode: self.textNode)
}
linkHighlightingNode.frame = self.textNode.frame
linkHighlightingNode.updateRects(rects)
} else if let linkHighlightingNode = self.linkHighlightingNode {
self.linkHighlightingNode = nil
linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in
linkHighlightingNode?.removeFromSupernode()
})
}
}
}
}
@@ -0,0 +1,402 @@
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AccountContext
import TextFormat
import UIKit
import AppBundle
import TelegramStringFormatting
import ContextUI
final class PeerInfoScreenContactInfoItem: PeerInfoScreenItem {
let id: AnyHashable
let username: String
let phoneNumber: String
let additionalText: String?
let usernameAction: ((ASDisplayNode) -> Void)?
let usernameLongTapAction: ((ASDisplayNode) -> Void)?
let phoneAction: ((ASDisplayNode) -> Void)?
let phoneLongTapAction: ((ASDisplayNode) -> Void)?
let linkItemAction: ((TextLinkItemActionType, TextLinkItem, ASDisplayNode, CGRect?) -> Void)?
let contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
let requestLayout: () -> Void
init(
id: AnyHashable,
username: String,
phoneNumber: String,
additionalText: String? = nil,
usernameAction: ((ASDisplayNode) -> Void)?,
usernameLongTapAction: ((ASDisplayNode) -> Void)?,
phoneAction: ((ASDisplayNode) -> Void)?,
phoneLongTapAction: ((ASDisplayNode) -> Void)?,
linkItemAction: ((TextLinkItemActionType, TextLinkItem, ASDisplayNode, CGRect?) -> Void)? = nil,
contextAction: ((ASDisplayNode, ContextGesture?, CGPoint?) -> Void)? = nil,
requestLayout: @escaping () -> Void
) {
self.id = id
self.username = username
self.phoneNumber = phoneNumber
self.additionalText = additionalText
self.usernameAction = usernameAction
self.usernameLongTapAction = usernameLongTapAction
self.phoneAction = phoneAction
self.phoneLongTapAction = phoneLongTapAction
self.linkItemAction = linkItemAction
self.contextAction = contextAction
self.requestLayout = requestLayout
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenContactInfoItemNode()
}
}
private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode {
private let containerNode: ContextControllerSourceNode
private let contextSourceNode: ContextExtractedContentContainingNode
private let extractedBackgroundImageNode: ASImageNode
private var extractedRect: CGRect?
private var nonExtractedRect: CGRect?
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let usernameNode: ImmediateTextNode
private let phoneNumberNode: ImmediateTextNode
private let additionalTextNode: ImmediateTextNode
private let measureTextNode: ImmediateTextNode
private let bottomSeparatorNode: ASDisplayNode
private var linkHighlightingNode: LinkHighlightingNode?
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenContactInfoItem?
private var theme: PresentationTheme?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.extractedBackgroundImageNode = ASImageNode()
self.extractedBackgroundImageNode.displaysAsynchronously = false
self.extractedBackgroundImageNode.alpha = 0.0
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.selectionNode.isUserInteractionEnabled = false
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.usernameNode = ImmediateTextNode()
self.usernameNode.displaysAsynchronously = false
self.usernameNode.isUserInteractionEnabled = false
self.phoneNumberNode = ImmediateTextNode()
self.phoneNumberNode.displaysAsynchronously = false
self.phoneNumberNode.isUserInteractionEnabled = false
self.additionalTextNode = ImmediateTextNode()
self.additionalTextNode.displaysAsynchronously = false
self.additionalTextNode.isUserInteractionEnabled = false
self.measureTextNode = ImmediateTextNode()
self.measureTextNode.displaysAsynchronously = false
self.measureTextNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.containerNode.addSubnode(self.contextSourceNode)
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
self.addSubnode(self.containerNode)
self.addSubnode(self.maskNode)
self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode)
self.contextSourceNode.contentNode.addSubnode(self.usernameNode)
self.contextSourceNode.contentNode.addSubnode(self.phoneNumberNode)
self.contextSourceNode.contentNode.addSubnode(self.additionalTextNode)
self.addSubnode(self.activateArea)
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction else {
gesture.cancel()
return
}
contextAction(strongSelf.contextSourceNode, gesture, nil)
}
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let strongSelf = self, let theme = strongSelf.theme else {
return
}
if isExtracted {
strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 52.0, color: theme.list.plainBackgroundColor)
}
if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect {
let rect = isExtracted ? extractedRect : nonExtractedRect
transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect)
}
transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
if !isExtracted {
self?.extractedBackgroundImageNode.image = nil
}
})
}
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { [weak self] point in
guard let strongSelf = self else {
return .keepWithSingleTap
}
if let _ = strongSelf.linkItemAtPoint(point) {
return .waitForSingleTap
}
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.updateTouchesAtPoint(point)
}
self.view.addGestureRecognizer(recognizer)
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap, .longTap:
if let item = self.item {
if let linkItem = self.linkItemAtPoint(location) {
item.linkItemAction?(gesture == .tap ? .tap : .longTap, linkItem, self.linkHighlightingNode ?? self, self.linkHighlightingNode?.rects.first)
} else if case .longTap = gesture {
if self.usernameNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(location) {
item.usernameLongTapAction?(self.usernameNode)
} else if self.phoneNumberNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(location) {
item.phoneLongTapAction?(self.phoneNumberNode)
}
} else if case .tap = gesture {
if self.usernameNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(location) {
item.usernameAction?(self.contextSourceNode)
} else if self.phoneNumberNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(location) {
item.phoneAction?(self.contextSourceNode)
}
}
}
default:
break
}
}
default:
break
}
}
private func linkItemAtPoint(_ point: CGPoint) -> TextLinkItem? {
let additionalTextNodeFrame = self.additionalTextNode.frame
if let (_, attributes) = self.additionalTextNode.attributesAtPoint(CGPoint(x: point.x - additionalTextNodeFrame.minX, y: point.y - additionalTextNodeFrame.minY)) {
if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
return .url(url: url, concealed: false)
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
return .mention(peerName)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
return .hashtag(hashtag.peerName, hashtag.hashtag)
} else {
return nil
}
}
return nil
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenContactInfoItem else {
return 10.0
}
self.item = item
self.theme = presentationData.theme
// if let action = item.action {
// self.selectionNode.pressed = { [weak self] in
// if let strongSelf = self {
// action(strongSelf.contextSourceNode)
// }
// }
// } else {
// self.selectionNode.pressed = nil
// }
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
self.usernameNode.attributedText = NSAttributedString(string: item.username, font: Font.regular(15.0), textColor: presentationData.theme.list.itemPrimaryTextColor)
self.phoneNumberNode.maximumNumberOfLines = 1
self.phoneNumberNode.cutout = nil
self.phoneNumberNode.attributedText = NSAttributedString(string: item.phoneNumber, font: Font.regular(15.0), textColor: presentationData.theme.list.itemAccentColor)
let fontSize: CGFloat = 15.0
let baseFont = Font.regular(fontSize)
let linkFont = baseFont
let boldFont = Font.medium(fontSize)
let italicFont = Font.italic(fontSize)
let boldItalicFont = Font.semiboldItalic(fontSize)
let titleFixedFont = Font.monospace(fontSize)
if let additionalText = item.additionalText {
let entities = generateTextEntities(additionalText, enabledTypes: [.mention])
let attributedAdditionalText = stringWithAppliedEntities(additionalText, entities: entities, baseColor: presentationData.theme.list.itemPrimaryTextColor, linkColor: presentationData.theme.list.itemAccentColor, baseFont: baseFont, linkFont: linkFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: titleFixedFont, blockQuoteFont: baseFont, underlineLinks: false, message: nil)
self.additionalTextNode.maximumNumberOfLines = 10
self.additionalTextNode.attributedText = attributedAdditionalText
} else {
self.additionalTextNode.attributedText = nil
}
let usernameSize = self.usernameNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let phoneSize = self.phoneNumberNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let additionalTextSize = self.additionalTextNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let topOffset = 12.0
var height = topOffset * 2.0
let usernameFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset), size: usernameSize)
let phoneFrame = CGRect(origin: CGPoint(x: usernameSize.width > 0.0 ? width - sideInset - phoneSize.width : sideInset, y: topOffset), size: phoneSize)
let textHeight = max(usernameSize.height, phoneSize.height)
height += textHeight
let additionalTextFrame = CGRect(origin: CGPoint(x: sideInset, y: topOffset + textHeight + 3.0), size: additionalTextSize)
transition.updateFrame(node: self.usernameNode, frame: usernameFrame)
transition.updateFrame(node: self.phoneNumberNode, frame: phoneFrame)
transition.updateFrame(node: self.additionalTextNode, frame: additionalTextFrame)
if additionalTextSize.height > 0.0 {
height += additionalTextSize.height + 3.0
}
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height))
self.activateArea.accessibilityLabel = item.username
self.activateArea.accessibilityValue = item.phoneNumber
let contentSize = CGSize(width: width, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.containerNode.isGestureEnabled = item.contextAction != nil
let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: contentSize.height))
let extractedRect = nonExtractedRect
self.extractedRect = extractedRect
self.nonExtractedRect = nonExtractedRect
if self.contextSourceNode.isExtractedToContextPreview {
self.extractedBackgroundImageNode.frame = extractedRect
} else {
self.extractedBackgroundImageNode.frame = nonExtractedRect
}
self.contextSourceNode.contentRect = extractedRect
return height
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
guard let _ = self.item, let theme = self.theme else {
return
}
var rects: [CGRect]?
var textNode: ASDisplayNode?
if let point = point {
if self.usernameNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(point) {
textNode = self.usernameNode
rects = [self.usernameNode.bounds]
} else if self.phoneNumberNode.frame.insetBy(dx: -10.0, dy: -10.0).contains(point) {
textNode = self.phoneNumberNode
rects = [self.phoneNumberNode.bounds]
} else if self.additionalTextNode.frame.contains(point) {
let mappedPoint = CGPoint(x: point.x - self.additionalTextNode.frame.minX, y: point.y - self.additionalTextNode.frame.minY)
if mappedPoint.y > 0.0, let (index, attributes) = self.additionalTextNode.attributesAtPoint(mappedPoint) {
let possibleNames: [String] = [
TelegramTextAttributes.URL,
TelegramTextAttributes.PeerMention,
TelegramTextAttributes.PeerTextMention,
TelegramTextAttributes.BotCommand,
TelegramTextAttributes.Hashtag
]
for name in possibleNames {
if let _ = attributes[NSAttributedString.Key(rawValue: name)] {
rects = self.additionalTextNode.attributeRects(name: name, at: index)
textNode = self.additionalTextNode
break
}
}
}
}
}
if let rects = rects, let textNode = textNode {
let linkHighlightingNode: LinkHighlightingNode
if let current = self.linkHighlightingNode {
linkHighlightingNode = current
} else {
linkHighlightingNode = LinkHighlightingNode(color: theme.list.itemAccentColor.withAlphaComponent(0.2))
self.linkHighlightingNode = linkHighlightingNode
self.contextSourceNode.contentNode.insertSubnode(linkHighlightingNode, belowSubnode: textNode)
}
linkHighlightingNode.frame = textNode.frame
linkHighlightingNode.updateRects(rects)
} else if let linkHighlightingNode = self.linkHighlightingNode {
self.linkHighlightingNode = nil
linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in
linkHighlightingNode?.removeFromSupernode()
})
}
}
}
@@ -0,0 +1,134 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import EncryptionKeyVisualization
import TelegramCore
import AccountContext
final class PeerInfoScreenDisclosureEncryptionKeyItem: PeerInfoScreenItem {
let id: AnyHashable
let text: String
let fingerprint: SecretChatKeyFingerprint
let action: (() -> Void)?
init(id: AnyHashable, text: String, fingerprint: SecretChatKeyFingerprint, action: (() -> Void)?) {
self.id = id
self.text = text
self.fingerprint = fingerprint
self.action = action
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenDisclosureEncryptionKeyItemNode()
}
}
private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let textNode: ImmediateTextNode
private let keyNode: ASImageNode
private let arrowNode: ASImageNode
private let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private var item: PeerInfoScreenDisclosureEncryptionKeyItem?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.keyNode = ASImageNode()
self.keyNode.displaysAsynchronously = false
self.keyNode.displayWithoutProcessing = true
self.keyNode.isUserInteractionEnabled = false
self.arrowNode = ASImageNode()
self.arrowNode.isLayerBacked = true
self.arrowNode.displaysAsynchronously = false
self.arrowNode.displayWithoutProcessing = true
self.arrowNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.addSubnode(self.textNode)
self.addSubnode(self.keyNode)
self.addSubnode(self.arrowNode)
self.addSubnode(self.maskNode)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenDisclosureEncryptionKeyItem else {
return 10.0
}
if self.item?.fingerprint != item.fingerprint {
self.keyNode.image = secretChatKeyImage(item.fingerprint, size: CGSize(width: 24.0, height: 24.0))
}
self.item = item
self.selectionNode.pressed = item.action
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
self.textNode.maximumNumberOfLines = 1
self.textNode.attributedText = NSAttributedString(string: item.text, font: Font.regular(17.0), textColor: presentationData.theme.list.itemPrimaryTextColor)
let textSize = self.textNode.updateLayout(CGSize(width: width - sideInset * 2.0 - 44.0, height: .greatestFiniteMagnitude))
let arrowInset: CGFloat = 18.0
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: 12.0), size: textSize)
let height = textSize.height + 24.0
if let arrowImage = PresentationResourcesItemList.disclosureArrowImage(presentationData.theme) {
self.arrowNode.image = arrowImage
let arrowFrame = CGRect(origin: CGPoint(x: width - 7.0 - arrowImage.size.width, y: floorToScreenPixels((height - arrowImage.size.height) / 2.0)), size: arrowImage.size)
transition.updateFrame(node: self.arrowNode, frame: arrowFrame)
}
if let image = self.keyNode.image {
self.keyNode.frame = CGRect(origin: CGPoint(x: width - sideInset - arrowInset - image.size.width, y: floor((height - image.size.height) / 2.0)), size: image.size)
}
transition.updateFrame(node: self.textNode, frame: textFrame)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
return height
}
}
@@ -0,0 +1,417 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import TextNodeWithEntities
import AccountContext
final class PeerInfoScreenDisclosureItem: PeerInfoScreenItem {
enum Label {
enum LabelColor {
case generic
case accent
}
case none
case text(String)
case attributedText(NSAttributedString)
case coloredText(String, LabelColor)
case badge(String, UIColor)
case semitransparentBadge(String, UIColor)
case titleBadge(String, UIColor)
case image(UIImage, CGSize)
case labelBadge(String)
var text: String {
switch self {
case .none, .image:
return ""
case let .attributedText(text):
return text.string
case let .text(text), let .coloredText(text, _), let .badge(text, _), let .semitransparentBadge(text, _), let .titleBadge(text, _), let .labelBadge(text):
return text
}
}
var badgeColor: UIColor? {
switch self {
case .none, .text, .coloredText, .image, .attributedText, .labelBadge:
return nil
case let .badge(_, color), let .semitransparentBadge(_, color), let .titleBadge(_, color):
return color
}
}
}
let id: AnyHashable
let label: Label
let additionalBadgeLabel: String?
let additionalBadgeIcon: UIImage?
let text: String
let icon: UIImage?
let iconSignal: Signal<UIImage?, NoError>?
let hasArrow: Bool
let action: (() -> Void)?
init(id: AnyHashable, label: Label = .none, additionalBadgeLabel: String? = nil, additionalBadgeIcon: UIImage? = nil, text: String, icon: UIImage? = nil, iconSignal: Signal<UIImage?, NoError>? = nil, hasArrow: Bool = true, action: (() -> Void)?) {
self.id = id
self.label = label
self.additionalBadgeLabel = additionalBadgeLabel
self.additionalBadgeIcon = additionalBadgeIcon
self.text = text
self.icon = icon
self.iconSignal = iconSignal
self.hasArrow = hasArrow
self.action = action
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenDisclosureItemNode()
}
}
private final class PeerInfoScreenDisclosureItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let labelBadgeNode: ASImageNode
private let labelNode: ImmediateTextNodeWithEntities
private var additionalLabelNode: ImmediateTextNode?
private var additionalLabelBadgeNode: ASImageNode?
private let textNode: ImmediateTextNode
private let arrowNode: ASImageNode
private let bottomSeparatorNode: ASDisplayNode
private let activateArea: AccessibilityAreaNode
private var iconDisposable = MetaDisposable()
private var item: PeerInfoScreenDisclosureItem?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displaysAsynchronously = false
self.labelBadgeNode = ASImageNode()
self.labelBadgeNode.displayWithoutProcessing = true
self.labelBadgeNode.displaysAsynchronously = false
self.labelBadgeNode.isLayerBacked = true
self.labelNode = ImmediateTextNodeWithEntities()
self.labelNode.displaysAsynchronously = false
self.labelNode.isUserInteractionEnabled = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.arrowNode = ASImageNode()
self.arrowNode.isLayerBacked = true
self.arrowNode.displaysAsynchronously = false
self.arrowNode.displayWithoutProcessing = true
self.arrowNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.addSubnode(self.maskNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.textNode)
self.addSubnode(self.arrowNode)
self.addSubnode(self.activateArea)
}
deinit {
self.iconDisposable.dispose()
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenDisclosureItem else {
return 10.0
}
let previousItem = self.item
self.item = item
self.selectionNode.pressed = item.action
let sideInset: CGFloat = 16.0 + safeInsets.left
let leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0)
let rightInset = sideInset + (item.hasArrow ? 18.0 : 0.0)
let separatorInset = item.icon == nil && item.iconSignal == nil ? sideInset : leftInset - 1.0
let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize)
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let textColorValue: UIColor = presentationData.theme.list.itemPrimaryTextColor
let labelColorValue: UIColor
let labelFont: UIFont
if case let .semitransparentBadge(_, color) = item.label {
labelColorValue = color
labelFont = Font.semibold(14.0)
} else if case .badge = item.label {
labelColorValue = presentationData.theme.list.itemCheckColors.foregroundColor
labelFont = Font.regular(15.0)
} else if case .titleBadge = item.label {
labelColorValue = presentationData.theme.list.itemCheckColors.foregroundColor
labelFont = Font.medium(11.0)
} else if case .labelBadge = item.label {
labelColorValue = presentationData.theme.list.itemCheckColors.foregroundColor
labelFont = Font.medium(12.0)
} else if case let .coloredText(_, color) = item.label {
switch color {
case .generic:
labelColorValue = presentationData.theme.list.itemSecondaryTextColor
case .accent:
labelColorValue = presentationData.theme.list.itemAccentColor
}
labelFont = titleFont
} else {
labelColorValue = presentationData.theme.list.itemSecondaryTextColor
labelFont = titleFont
}
self.labelNode.arguments = TextNodeWithEntities.Arguments(
context: context,
cache: context.animationCache,
renderer: context.animationRenderer,
placeholderColor: .clear,
attemptSynchronous: true
)
if case let .attributedText(text) = item.label {
self.labelNode.attributedText = text
} else {
self.labelNode.attributedText = NSAttributedString(string: item.label.text, font: labelFont, textColor: labelColorValue)
}
self.textNode.maximumNumberOfLines = 1
self.textNode.attributedText = NSAttributedString(string: item.text, font: titleFont, textColor: textColorValue)
let textSize = self.textNode.updateLayout(CGSize(width: width - (leftInset + rightInset), height: .greatestFiniteMagnitude))
var labelConstrainWidth = width - textSize.width - (leftInset + rightInset)
if case .semitransparentBadge = item.label {
labelConstrainWidth -= 16.0
}
let labelSize = self.labelNode.updateLayout(CGSize(width: labelConstrainWidth, height: .greatestFiniteMagnitude))
let height = textSize.height + 32.0
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((height - textSize.height) / 2.0)), size: textSize)
if item.icon != nil || item.iconSignal != nil {
if self.iconNode.supernode == nil {
self.addSubnode(self.iconNode)
}
let iconSize: CGSize
if let icon = item.icon {
self.iconNode.image = icon
iconSize = icon.size
} else if let iconSignal = item.iconSignal {
if previousItem?.text != item.text {
self.iconNode.image = nil
self.iconDisposable.set((iconSignal
|> deliverOnMainQueue).startStrict(next: { [weak self] icon in
if let self {
self.iconNode.image = icon
}
}))
}
iconSize = CGSize(width: 29.0, height: 29.0)
} else {
iconSize = CGSize(width: 29.0, height: 29.0)
}
let iconFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((height - iconSize.height) / 2.0)), size: iconSize)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
} else if self.iconNode.supernode != nil {
self.iconNode.image = nil
self.iconNode.removeFromSupernode()
}
if item.hasArrow, let arrowImage = PresentationResourcesItemList.disclosureArrowImage(presentationData.theme) {
self.arrowNode.image = arrowImage
let arrowFrame = CGRect(origin: CGPoint(x: width - 7.0 - arrowImage.size.width - safeInsets.right, y: floorToScreenPixels((height - arrowImage.size.height) / 2.0)), size: arrowImage.size)
transition.updateFrame(node: self.arrowNode, frame: arrowFrame)
}
var badgeDiameter: CGFloat = 20.0
if case let .image(image, imageSize) = item.label {
self.labelBadgeNode.image = image
badgeDiameter = imageSize.height
if self.labelBadgeNode.supernode == nil {
self.insertSubnode(self.labelBadgeNode, belowSubnode: self.labelNode)
}
} else if case let .semitransparentBadge(text, badgeColor) = item.label, !text.isEmpty {
badgeDiameter = 24.0
if previousItem?.label.badgeColor != badgeColor {
self.labelBadgeNode.image = generateStretchableFilledCircleImage(diameter: badgeDiameter, color: badgeColor.withAlphaComponent(0.1))
}
if self.labelBadgeNode.supernode == nil {
self.insertSubnode(self.labelBadgeNode, belowSubnode: self.labelNode)
}
} else if case let .badge(text, badgeColor) = item.label, !text.isEmpty {
if previousItem?.label.badgeColor != badgeColor {
self.labelBadgeNode.image = generateStretchableFilledCircleImage(diameter: badgeDiameter, color: badgeColor)
}
if self.labelBadgeNode.supernode == nil {
self.insertSubnode(self.labelBadgeNode, belowSubnode: self.labelNode)
}
} else if case let .titleBadge(text, badgeColor) = item.label, !text.isEmpty {
if previousItem?.label.badgeColor != badgeColor {
self.labelBadgeNode.image = generateFilledRoundedRectImage(size: CGSize(width: 16.0, height: 16.0), cornerRadius: 5.0, color: badgeColor)?.stretchableImage(withLeftCapWidth: 6, topCapHeight: 6)
}
if self.labelBadgeNode.supernode == nil {
self.insertSubnode(self.labelBadgeNode, belowSubnode: self.labelNode)
}
} else if case let .labelBadge(text) = item.label, !text.isEmpty {
let badgeColor = presentationData.theme.list.itemCheckColors.fillColor
if previousItem?.label.badgeColor != badgeColor {
self.labelBadgeNode.image = generateFilledRoundedRectImage(size: CGSize(width: 16.0, height: 16.0), cornerRadius: 5.0, color: badgeColor)?.stretchableImage(withLeftCapWidth: 6, topCapHeight: 6)
}
if self.labelBadgeNode.supernode == nil {
self.insertSubnode(self.labelBadgeNode, belowSubnode: self.labelNode)
}
} else {
self.labelBadgeNode.removeFromSupernode()
}
if item.additionalBadgeLabel != nil {
if previousItem?.additionalBadgeLabel == nil {
let additionalLabelBadgeNode: ASImageNode
if let current = self.additionalLabelBadgeNode {
additionalLabelBadgeNode = current
} else {
additionalLabelBadgeNode = ASImageNode()
additionalLabelBadgeNode.isUserInteractionEnabled = false
self.additionalLabelBadgeNode = additionalLabelBadgeNode
self.insertSubnode(additionalLabelBadgeNode, belowSubnode: self.labelNode)
}
additionalLabelBadgeNode.image = generateFilledRoundedRectImage(size: CGSize(width: 16.0, height: 16.0), cornerRadius: 5.0, color: presentationData.theme.list.itemCheckColors.fillColor)?.stretchableImage(withLeftCapWidth: 6, topCapHeight: 6)
}
}
if let additionalBadgeIcon = item.additionalBadgeIcon {
let additionalLabelBadgeNode: ASImageNode
if let current = self.additionalLabelBadgeNode {
additionalLabelBadgeNode = current
} else {
additionalLabelBadgeNode = ASImageNode()
additionalLabelBadgeNode.isUserInteractionEnabled = false
self.additionalLabelBadgeNode = additionalLabelBadgeNode
self.insertSubnode(additionalLabelBadgeNode, belowSubnode: self.labelNode)
}
additionalLabelBadgeNode.image = additionalBadgeIcon
} else if item.additionalBadgeLabel == nil {
if let additionalLabelBadgeNode = self.additionalLabelBadgeNode {
self.additionalLabelBadgeNode = nil
additionalLabelBadgeNode.removeFromSupernode()
}
}
var badgeWidth = max(badgeDiameter, labelSize.width + 10.0)
if case .semitransparentBadge = item.label {
badgeWidth += 2.0
}
let labelFrame: CGRect
if case .semitransparentBadge = item.label {
labelFrame = CGRect(origin: CGPoint(x: width - rightInset - badgeWidth + (badgeWidth - labelSize.width) / 2.0, y: floor((height - labelSize.height) / 2.0)), size: labelSize)
} else if case .badge = item.label {
labelFrame = CGRect(origin: CGPoint(x: width - rightInset - badgeWidth + (badgeWidth - labelSize.width) / 2.0, y: floor((height - labelSize.height) / 2.0)), size: labelSize)
} else if case .titleBadge = item.label {
labelFrame = CGRect(origin: CGPoint(x: textFrame.maxX + 10.0, y: floor((height - labelSize.height) / 2.0) + 1.0), size: labelSize)
} else if case .labelBadge = item.label {
labelFrame = CGRect(origin: CGPoint(x: width - rightInset - badgeWidth + (badgeWidth - labelSize.width) / 2.0, y: floor((height - labelSize.height) / 2.0)), size: labelSize)
} else {
labelFrame = CGRect(origin: CGPoint(x: width - rightInset - labelSize.width, y: floorToScreenPixels((height - labelSize.height) / 2.0)), size: labelSize)
}
if let additionalBadgeLabel = item.additionalBadgeLabel {
let additionalLabelNode: ImmediateTextNode
if let current = self.additionalLabelNode {
additionalLabelNode = current
} else {
additionalLabelNode = ImmediateTextNode()
additionalLabelNode.isUserInteractionEnabled = false
self.additionalLabelNode = additionalLabelNode
self.addSubnode(additionalLabelNode)
}
additionalLabelNode.attributedText = NSAttributedString(string: additionalBadgeLabel, font: Font.medium(11.0), textColor: presentationData.theme.list.itemCheckColors.foregroundColor)
let additionalLabelSize = additionalLabelNode.updateLayout(CGSize(width: labelConstrainWidth, height: .greatestFiniteMagnitude))
additionalLabelNode.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 10.0, y: floor((height - additionalLabelSize.height) / 2.0) + 1.0), size: additionalLabelSize)
} else if let additionalLabelNode = self.additionalLabelNode {
self.additionalLabelNode = nil
additionalLabelNode.removeFromSupernode()
}
if let additionalLabelBadgeNode = self.additionalLabelBadgeNode, let image = additionalLabelBadgeNode.image {
if item.additionalBadgeLabel != nil, let additionalLabelNode = self.additionalLabelNode {
additionalLabelBadgeNode.frame = additionalLabelNode.frame.insetBy(dx: -4.0, dy: -2.0 + UIScreenPixel)
} else {
let additionalLabelSize = image.size
additionalLabelBadgeNode.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floor((height - additionalLabelSize.height) / 2.0) + 1.0), size: additionalLabelSize)
}
}
let labelBadgeNodeFrame: CGRect
if case let .image(_, imageSize) = item.label {
labelBadgeNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - imageSize.width, y: floorToScreenPixels(textFrame.midY - imageSize.height / 2.0)), size: imageSize)
} else if case .titleBadge = item.label {
labelBadgeNodeFrame = labelFrame.insetBy(dx: -4.0, dy: -2.0 + UIScreenPixel)
} else if case .labelBadge = item.label {
labelBadgeNodeFrame = labelFrame.insetBy(dx: -4.0, dy: -2.0 + UIScreenPixel)
} else if let additionalLabelNode = self.additionalLabelNode {
labelBadgeNodeFrame = additionalLabelNode.frame.insetBy(dx: -4.0, dy: -2.0 + UIScreenPixel)
} else {
labelBadgeNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - badgeWidth, y: floorToScreenPixels(labelFrame.midY - badgeDiameter / 2.0)), size: CGSize(width: badgeWidth, height: badgeDiameter))
}
self.activateArea.accessibilityLabel = item.text
self.activateArea.accessibilityValue = item.label.text
transition.updateFrame(node: self.labelBadgeNode, frame: labelBadgeNodeFrame)
if self.labelNode.bounds.size != labelFrame.size {
self.labelNode.frame = labelFrame
} else {
transition.updateFrame(node: self.labelNode, frame: labelFrame)
}
transition.updateFrame(node: self.textNode, frame: textFrame)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
let separatorRightInset: CGFloat = 16.0
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: height - UIScreenPixel), size: CGSize(width: width - separatorInset - separatorRightInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
return height
}
}
@@ -0,0 +1,84 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AccountContext
final class PeerInfoScreenHeaderItem: PeerInfoScreenItem {
let id: AnyHashable
let text: String
let label: String?
init(id: AnyHashable, text: String, label: String? = nil) {
self.id = id
self.text = text
self.label = label
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenHeaderItemNode()
}
}
private final class PeerInfoScreenHeaderItemNode: PeerInfoScreenItemNode {
private let textNode: ImmediateTextNode
private let labelNode: ImmediateTextNode
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenHeaderItem?
override init() {
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.labelNode = ImmediateTextNode()
self.labelNode.displaysAsynchronously = false
self.labelNode.isUserInteractionEnabled = false
self.activateArea = AccessibilityAreaNode()
self.activateArea.accessibilityTraits = [.staticText, .header]
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.activateArea)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenHeaderItem else {
return 10.0
}
self.item = item
let sideInset: CGFloat = 16.0 + safeInsets.left
let verticalInset: CGFloat = 7.0
self.textNode.maximumNumberOfLines = 0
self.textNode.attributedText = NSAttributedString(string: item.text, font: Font.regular(13.0), textColor: presentationData.theme.list.freeTextColor)
self.activateArea.accessibilityLabel = item.text
let textSize = self.textNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude))
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: textSize)
self.labelNode.maximumNumberOfLines = 0
self.labelNode.attributedText = NSAttributedString(string: item.label ?? "", font: Font.regular(13.0), textColor: presentationData.theme.list.freeTextColor)
let labelSize = self.labelNode.updateLayout(CGSize(width: max(0.0, width - sideInset * 2.0 - textSize.width - 4.0), height: .greatestFiniteMagnitude))
let labelFrame = CGRect(origin: CGPoint(x: width - sideInset - labelSize.width, y: verticalInset), size: labelSize)
let height = textSize.height + verticalInset * 2.0
transition.updateFrame(node: self.textNode, frame: textFrame)
transition.updateFrame(node: self.labelNode, frame: labelFrame)
self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
return height
}
}
@@ -0,0 +1,126 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListPeerItem
import SwiftSignalKit
import AccountContext
import Postbox
import TelegramCore
import ItemListUI
final class PeerInfoScreenInfoItem: PeerInfoScreenItem {
let id: AnyHashable
let title: String
let text: InfoListItemText
let isWarning: Bool
let linkAction: ((InfoListItemLinkAction) -> Void)?
init(
id: AnyHashable,
title: String,
text: InfoListItemText,
isWarning: Bool = false,
linkAction: ((InfoListItemLinkAction) -> Void)?
) {
self.id = id
self.title = title
self.text = text
self.isWarning = isWarning
self.linkAction = linkAction
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenInfoItemNode()
}
}
private final class PeerInfoScreenInfoItemNode: PeerInfoScreenItemNode {
private let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private var item: PeerInfoScreenInfoItem?
private var itemNode: InfoItemNode?
override init() {
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.bottomSeparatorNode)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenInfoItem else {
return 10.0
}
self.item = item
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let infoItem = InfoListItem(presentationData: ItemListPresentationData(presentationData), systemStyle: .glass, title: item.title, text: item.text, style: .blocks, hasDecorations: false, isWarning: item.isWarning, linkAction: { link in
item.linkAction?(link)
}, closeAction: nil)
let params = ListViewItemLayoutParams(width: width, leftInset: safeInsets.left, rightInset: safeInsets.right, availableHeight: 1000.0)
let itemNode: InfoItemNode
if let current = self.itemNode {
itemNode = current
infoItem.updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: nil, nextItem: nil, animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var itemNodeValue: ListViewItemNode?
infoItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { node, apply in
itemNodeValue = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode = itemNodeValue as! InfoItemNode
self.itemNode = itemNode
self.addSubnode(itemNode)
}
let height = itemNode.contentSize.height
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(), size: itemNode.bounds.size))
var separatorInset: CGFloat = sideInset
if bottomItem != nil {
separatorInset += 49.0
}
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
if self.maskNode.supernode == nil {
self.addSubnode(self.maskNode)
}
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
return height
}
}
@@ -0,0 +1,289 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListPeerItem
import SwiftSignalKit
import AccountContext
import Postbox
import TelegramCore
import ItemListUI
enum PeerInfoScreenMemberItemAction {
case open
case promote
case restrict
case remove
}
final class PeerInfoScreenMemberItem: PeerInfoScreenItem {
let id: AnyHashable
let context: ItemListPeerItem.Context
let enclosingPeer: Peer?
let member: PeerInfoMember
let badge: String?
let isAccount: Bool
let action: ((PeerInfoScreenMemberItemAction) -> Void)?
let contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
let openStories: ((UIView) -> Void)?
init(
id: AnyHashable,
context: ItemListPeerItem.Context,
enclosingPeer: Peer?,
member: PeerInfoMember,
badge: String? = nil,
isAccount: Bool,
action: ((PeerInfoScreenMemberItemAction) -> Void)?,
contextAction: ((ASDisplayNode, ContextGesture?) -> Void)? = nil,
openStories: ((UIView) -> Void)? = nil
) {
self.id = id
self.context = context
self.enclosingPeer = enclosingPeer
self.member = member
self.badge = badge
self.isAccount = isAccount
self.action = action
self.contextAction = contextAction
self.openStories = openStories
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenMemberItemNode()
}
}
private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let bottomSeparatorNode: ASDisplayNode
private var item: PeerInfoScreenMemberItem?
private var itemNode: ItemListPeerItemNode?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.selectionNode.isUserInteractionEnabled = false
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { point in
return .keepWithSingleTap
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.updateTouchesAtPoint(point)
}
self.view.addGestureRecognizer(recognizer)
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
if let item = self.item {
item.action?(.open)
}
default:
break
}
}
default:
break
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenMemberItem else {
return 10.0
}
self.item = item
self.selectionNode.pressed = item.action.flatMap { action in
return {
action(.open)
}
}
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let label: String?
if let rank = item.member.rank {
label = rank
} else {
switch item.member.role {
case .creator:
label = presentationData.strings.GroupInfo_LabelOwner
case .admin:
label = presentationData.strings.GroupInfo_LabelAdmin
case .member:
label = nil
}
}
let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer, member: item.member)
var options: [ItemListPeerItemRevealOption] = []
if actions.contains(.promote) && item.enclosingPeer is TelegramChannel {
options.append(ItemListPeerItemRevealOption(type: .neutral, title: presentationData.strings.GroupInfo_ActionPromote, action: {
item.action?(.promote)
}))
}
if actions.contains(.restrict) {
if item.enclosingPeer is TelegramChannel {
options.append(ItemListPeerItemRevealOption(type: .warning, title: presentationData.strings.GroupInfo_ActionRestrict, action: {
item.action?(.restrict)
}))
}
options.append(ItemListPeerItemRevealOption(type: .destructive, title: presentationData.strings.Common_Delete, action: {
item.action?(.remove)
}))
}
if actions.contains(.logout) {
options.append(ItemListPeerItemRevealOption(type: .destructive, title: presentationData.strings.Settings_Context_Logout, action: {
item.action?(.remove)
}))
}
let itemLabel: ItemListPeerItemLabel
if let label = label {
itemLabel = .text(label, .standard)
} else if let badge = item.badge {
itemLabel = .badge(badge)
} else {
itemLabel = .none
}
let itemHeight: ItemListPeerItemHeight
let itemText: ItemListPeerItemText
var synchronousLoads = false
if case .account = item.member {
itemHeight = .generic
itemText = .none
synchronousLoads = true
} else {
itemHeight = .peerList
itemText = .presence
}
let peerItem = ItemListPeerItem(presentationData: ItemListPresentationData(presentationData), systemStyle: .glass, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: item.context, peer: EnginePeer(item.member.peer), height: itemHeight, presence: item.member.presence.flatMap(EnginePeer.Presence.init), text: itemText, label: itemLabel, editing: ItemListPeerItemEditing(editable: !options.isEmpty, editing: false, revealed: nil), revealOptions: ItemListPeerItemRevealOptions(options: options), switchValue: nil, enabled: true, selectable: false, animateFirstAvatarTransition: !item.isAccount, sectionId: 0, action: nil, setPeerIdWithRevealedOptions: { lhs, rhs in
}, removePeer: { _ in
}, contextAction: item.contextAction, hasTopStripe: false, hasTopGroupInset: false, noInsets: true, noCorners: true, displayDecorations: false, storyStats: item.member.storyStats, openStories: { [weak self] sourceView in
guard let self, let item = self.item else {
return
}
item.openStories?(sourceView)
})
let params = ListViewItemLayoutParams(width: width, leftInset: safeInsets.left, rightInset: safeInsets.right, availableHeight: 1000.0)
let itemNode: ItemListPeerItemNode
if let current = self.itemNode {
itemNode = current
peerItem.updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: nil, nextItem: nil, animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var itemNodeValue: ListViewItemNode?
peerItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: synchronousLoads, previousItem: nil, nextItem: nil, completion: { node, apply in
itemNodeValue = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode = itemNodeValue as! ItemListPeerItemNode
self.itemNode = itemNode
self.addSubnode(itemNode)
}
itemNode.visibility = .visible(1.0, .infinite)
let height = itemNode.contentSize.height
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(), size: itemNode.bounds.size))
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
if self.maskNode.supernode == nil {
self.addSubnode(self.maskNode)
}
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
var separatorInset: CGFloat = sideInset
if bottomItem != nil {
separatorInset += 49.0
}
let separatorRightInset: CGFloat = 16.0
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: height - UIScreenPixel), size: CGSize(width: width - separatorInset - separatorRightInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
return height
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
guard let item = self.item else {
return
}
var highlight = point != nil
if case .account = item.member {
} else if item.context.accountPeerId == item.member.id {
highlight = false
}
if let point, let itemNode = self.itemNode, let value = itemNode.view.hitTest(self.view.convert(point, to: itemNode.view), with: nil), value is UIControl {
highlight = false
}
if highlight {
self.selectionNode.updateIsHighlighted(true)
} else {
self.selectionNode.updateIsHighlighted(false)
}
}
}
@@ -0,0 +1,127 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TelegramCore
import AccountContext
import ComponentFlow
import TextFieldComponent
final class PeerInfoScreenNoteListItem: PeerInfoScreenItem {
let id: AnyHashable
let initialValue: NSAttributedString
let valueUpdated: (NSAttributedString) -> Void
let requestLayout: (Bool) -> Void
init(
id: AnyHashable,
initialValue: NSAttributedString,
valueUpdated: @escaping (NSAttributedString) -> Void,
requestLayout: @escaping (Bool) -> Void
) {
self.id = id
self.initialValue = initialValue
self.valueUpdated = valueUpdated
self.requestLayout = requestLayout
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenNoteListItemNode()
}
}
final class PeerInfoScreenNoteListItemNode: PeerInfoScreenItemNode {
private let maskNode: ASImageNode
private let textField = ComponentView<Empty>()
private let textFieldExternalState = TextFieldComponent.ExternalState()
private let state = EmptyComponentState()
private let bottomSeparatorNode: ASDisplayNode
private var item: PeerInfoScreenNoteListItem?
private var presentationData: PresentationData?
private var theme: PresentationTheme?
override init() {
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
super.init()
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.maskNode)
}
func focus() {
if let textView = self.textField.view as? TextFieldComponent.View {
textView.activateInput()
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenNoteListItem else {
return 10.0
}
var resetText: NSAttributedString?
if self.item?.initialValue != item.initialValue {
resetText = item.initialValue
}
self.item = item
self.presentationData = presentationData
self.theme = presentationData.theme
let sideInset: CGFloat = 1.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
self.state._updated = { [weak self] transition, _ in
guard let self else {
return
}
self.item?.requestLayout(!transition.animation.isImmediate)
}
var characterLimit: Int = 128
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["contact_note_length_limit"] as? Double {
characterLimit = Int(value)
}
self.textField.parentState = self.state
let textFieldSize = self.textField.update(
transition: .immediate,
component: AnyComponent(TextFieldComponent(context: context, theme: presentationData.theme, strings: presentationData.strings, externalState: self.textFieldExternalState, fontSize: 17.0, textColor: presentationData.theme.list.itemPrimaryTextColor, accentColor: presentationData.theme.list.itemAccentColor, insets: UIEdgeInsets(top: 9.0, left: 8.0, bottom: 10.0, right: 8.0), hideKeyboard: false, customInputView: nil, placeholder: NSAttributedString(string: presentationData.strings.PeerInfo_AddNotesPlaceholder, font: Font.regular(17.0), textColor: presentationData.theme.list.itemPlaceholderTextColor), resetText: resetText, isOneLineWhenUnfocused: false, characterLimit: characterLimit, formatMenuAvailability: .available([.bold, .italic, .underline, .strikethrough, .spoiler]), lockedFormatAction: {}, present: { c in }, paste: { _ in })),
environment: {},
containerSize: CGSize(width: width - sideInset * 2.0, height: .greatestFiniteMagnitude)
)
let textFieldFrame = CGRect(origin: CGPoint(x: sideInset, y: 7.0), size: textFieldSize)
let height: CGFloat = 12.0 + textFieldSize.height
if let textFieldView = self.textField.view {
if textFieldView.superview == nil {
self.view.addSubview(textFieldView)
}
transition.updateFrame(view: textFieldView, frame: textFieldFrame)
}
item.valueUpdated(self.textFieldExternalState.text)
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
return height
}
}
@@ -0,0 +1,755 @@
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AccountContext
import TextFormat
import UIKit
import AppBundle
import TelegramStringFormatting
import ContextUI
import TelegramCore
import ChatListUI
import Postbox
import StoryContainerScreen
import AvatarNode
final class PeerInfoScreenPersonalChannelItem: PeerInfoScreenItem {
let id: AnyHashable
let context: AccountContext
let data: PeerInfoPersonalChannelData
let controller: () -> ViewController?
let action: () -> Void
init(
id: AnyHashable,
context: AccountContext,
data: PeerInfoPersonalChannelData,
controller: @escaping () -> ViewController?,
action: @escaping () -> Void
) {
self.id = id
self.context = context
self.data = data
self.controller = controller
self.action = action
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenPersonalChannelItemNode()
}
}
private final class LoadingOverlayShimmerNode: ASDisplayNode {
private var currentBackgroundColor: UIColor?
private var currentForegroundColor: UIColor?
private let imageNodeContainer: ASDisplayNode
private let imageNode: ASImageNode
private var absoluteLocation: (CGRect, CGSize)?
private var isCurrentlyInHierarchy = false
private var shouldBeAnimating = false
override init() {
self.imageNodeContainer = ASDisplayNode()
self.imageNodeContainer.isLayerBacked = true
self.imageNode = ASImageNode()
self.imageNode.isLayerBacked = true
self.imageNode.displaysAsynchronously = false
self.imageNode.displayWithoutProcessing = true
self.imageNode.contentMode = .scaleToFill
super.init()
self.imageNodeContainer.addSubnode(self.imageNode)
self.addSubnode(self.imageNodeContainer)
}
override func didEnterHierarchy() {
super.didEnterHierarchy()
self.isCurrentlyInHierarchy = true
self.updateAnimation()
}
override func didExitHierarchy() {
super.didExitHierarchy()
self.isCurrentlyInHierarchy = false
self.updateAnimation()
}
func update(backgroundColor: UIColor, foregroundColor: UIColor) {
if let currentBackgroundColor = self.currentBackgroundColor, currentBackgroundColor.isEqual(backgroundColor), let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor) {
return
}
self.currentBackgroundColor = backgroundColor
self.currentForegroundColor = foregroundColor
self.backgroundColor = backgroundColor
self.imageNode.image = generateImage(CGSize(width: 4.0, height: 320.0), opaque: true, scale: 1.0, rotatedContext: { size, context in
context.setFillColor(backgroundColor.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.clip(to: CGRect(origin: CGPoint(), size: size))
let transparentColor = backgroundColor.cgColor
let peakColor = foregroundColor.cgColor
var locations: [CGFloat] = [0.0, 0.5, 1.0]
let colors: [CGColor] = [transparentColor, peakColor, transparentColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
})
}
func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize {
return
}
let sizeUpdated = self.absoluteLocation?.1 != containerSize
let frameUpdated = self.absoluteLocation?.0 != rect
self.absoluteLocation = (rect, containerSize)
if sizeUpdated {
if self.shouldBeAnimating {
self.imageNode.layer.removeAnimation(forKey: "shimmer")
self.addImageAnimation()
}
}
if frameUpdated {
self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize)
}
self.updateAnimation()
}
private func updateAnimation() {
let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil
if shouldBeAnimating != self.shouldBeAnimating {
self.shouldBeAnimating = shouldBeAnimating
if shouldBeAnimating {
self.addImageAnimation()
} else {
self.imageNode.layer.removeAnimation(forKey: "shimmer")
}
}
}
private func addImageAnimation() {
guard let containerSize = self.absoluteLocation?.1 else {
return
}
let gradientHeight: CGFloat = 250.0
self.imageNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -gradientHeight), size: CGSize(width: containerSize.width, height: gradientHeight))
let animation = self.imageNode.layer.makeAnimation(from: 0.0 as NSNumber, to: (containerSize.height + gradientHeight) as NSNumber, keyPath: "position.y", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 1.3 * 1.0, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
animation.beginTime = 1.0
self.imageNode.layer.add(animation, forKey: "shimmer")
}
}
public final class LoadingOverlayNode: ASDisplayNode {
private let effectNode: LoadingOverlayShimmerNode
private let maskNode: ASImageNode
private var currentParams: (size: CGSize, presentationData: PresentationData)?
override public init() {
self.effectNode = LoadingOverlayShimmerNode()
self.maskNode = ASImageNode()
super.init()
self.isUserInteractionEnabled = false
self.addSubnode(self.effectNode)
self.effectNode.view.mask = self.maskNode.view
}
public func update(context: AccountContext, size: CGSize, isInlineMode: Bool, presentationData: PresentationData, transition: ContainedViewLayoutTransition) {
if self.currentParams?.size != size || self.currentParams?.presentationData !== presentationData {
self.currentParams = (size, presentationData)
let chatListPresentationData = ChatListPresentationData(theme: presentationData.theme, fontSize: presentationData.chatFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true)
let peer1: EnginePeer = .user(TelegramUser(id: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(0)), accessHash: nil, firstName: "FirstName", lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil))
let timestamp1: Int32 = 100000
let peers: [EnginePeer.Id: EnginePeer] = [:]
let interaction = ChatListNodeInteraction(context: context, animationCache: context.animationCache, animationRenderer: context.animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _, _ in }, disabledPeerSelected: { _, _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in
}, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, _, gesture, _ in
gesture?.cancel()
}, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: {}, openPasswordSetup: {}, openPremiumIntro: {}, openPremiumGift: { _, _ in }, openPremiumManagement: {}, openActiveSessions: {}, openBirthdaySetup: {}, performActiveSessionAction: { _, _ in }, openChatFolderUpdates: {}, hideChatFolderUpdates: {}, openStories: { _, _ in }, openStarsTopup: { _ in
}, dismissNotice: { _ in
}, editPeer: { _ in
}, openWebApp: { _ in
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
let items = (0 ..< 1).map { _ -> ChatListItem in
let message = EngineMessage(
stableId: 0,
stableVersion: 0,
id: EngineMessage.Id(peerId: peer1.id, namespace: 0, id: 0),
globallyUniqueId: nil,
groupingKey: nil,
groupInfo: nil,
threadId: nil,
timestamp: timestamp1,
flags: [],
tags: [],
globalTags: [],
localTags: [],
customTags: [],
forwardInfo: nil,
author: peer1,
text: "Text",
attributes: [],
media: [],
peers: peers,
associatedMessages: [:],
associatedMessageIds: [],
associatedMedia: [:],
associatedThreadInfo: nil,
associatedStories: [:]
)
let readState = EnginePeerReadCounters()
return ChatListItem(presentationData: chatListPresentationData, context: context, chatListLocation: .chatList(groupId: .root), filterData: nil, index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: 0, messageIndex: EngineMessage.Index(id: EngineMessage.Id(peerId: peer1.id, namespace: 0, id: 0), timestamp: timestamp1))), content: .peer(ChatListItemContent.PeerData(
messages: [message],
peer: EngineRenderedPeer(peer: peer1),
threadInfo: nil,
combinedReadState: readState,
isRemovedFromTotalUnreadCount: false,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
draftState: nil,
mediaDraftContentType: nil,
inputActivities: nil,
promoInfo: nil,
ignoreUnreadBadge: false,
displayAsMessage: false,
hasFailedMessages: false,
forumTopicData: nil,
topForumTopicItems: [],
autoremoveTimeout: nil,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false,
tags: []
)), editing: false, hasActiveRevealControls: false, selected: false, header: nil, enabledContextActions: nil, hiddenOffset: false, interaction: interaction)
}
var itemNodes: [ChatListItemNode] = []
for i in 0 ..< items.count {
items[i].nodeConfiguredForParams(async: { f in f() }, params: ListViewItemLayoutParams(width: size.width, leftInset: 0.0, rightInset: 0.0, availableHeight: 100.0), synchronousLoads: false, previousItem: i == 0 ? nil : items[i - 1], nextItem: (i == items.count - 1) ? nil : items[i + 1], completion: { node, apply in
if let itemNode = node as? ChatListItemNode {
itemNodes.append(itemNode)
}
apply().1(ListViewItemApply(isOnScreen: true))
})
}
self.maskNode.image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
var currentY: CGFloat = 0.0
let fakeLabelPlaceholderHeight: CGFloat = 8.0
func fillLabelPlaceholderRect(origin: CGPoint, width: CGFloat) {
let startPoint = origin
let diameter = fakeLabelPlaceholderHeight
context.fillEllipse(in: CGRect(origin: startPoint, size: CGSize(width: diameter, height: diameter)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: startPoint.x + width - diameter, y: startPoint.y), size: CGSize(width: diameter, height: diameter)))
context.fill(CGRect(origin: CGPoint(x: startPoint.x + diameter / 2.0, y: startPoint.y), size: CGSize(width: width - diameter, height: diameter)))
}
while currentY < size.height {
let sampleIndex = 0
let itemHeight: CGFloat = itemNodes[sampleIndex].contentSize.height
context.setFillColor(UIColor.black.cgColor)
let textFrame = itemNodes[sampleIndex].textNode.textNode.frame.offsetBy(dx: 0.0, dy: currentY)
fillLabelPlaceholderRect(origin: CGPoint(x: textFrame.minX, y: currentY + itemHeight - floor(itemNodes[sampleIndex].titleNode.frame.midY - fakeLabelPlaceholderHeight / 2.0) - fakeLabelPlaceholderHeight), width: 60.0)
fillLabelPlaceholderRect(origin: CGPoint(x: textFrame.minX, y: currentY + floor((itemHeight - fakeLabelPlaceholderHeight) / 2.0)), width: 120.0)
fillLabelPlaceholderRect(origin: CGPoint(x: textFrame.minX + 120.0 + 10.0, y: currentY + floor((itemHeight - fakeLabelPlaceholderHeight) / 2.0)), width: 60.0)
let dateFrame = itemNodes[sampleIndex].dateNode.frame.offsetBy(dx: 0.0, dy: currentY)
fillLabelPlaceholderRect(origin: CGPoint(x: dateFrame.maxX - 30.0, y: dateFrame.minY + 4.0), width: 30.0)
currentY += itemHeight
}
})
self.effectNode.update(backgroundColor: presentationData.theme.list.mediaPlaceholderColor, foregroundColor: presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4))
self.effectNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: size), within: size)
}
transition.updateFrame(node: self.maskNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size))
transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size))
}
}
private final class PeerInfoScreenPersonalChannelItemNode: PeerInfoScreenItemNode {
private let containerNode: ContextControllerSourceNode
private let contextSourceNode: ContextExtractedContentContainingNode
private let extractedBackgroundImageNode: ASImageNode
private var extractedRect: CGRect?
private var nonExtractedRect: CGRect?
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let bottomSeparatorNode: ASDisplayNode
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenPersonalChannelItem?
private var presentationData: PresentationData?
private var theme: PresentationTheme?
private var itemNode: ListViewItemNode?
private var loadingOverlayNode: LoadingOverlayNode?
override init() {
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.extractedBackgroundImageNode = ASImageNode()
self.extractedBackgroundImageNode.displaysAsynchronously = false
self.extractedBackgroundImageNode.alpha = 0.0
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.selectionNode.isUserInteractionEnabled = false
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.containerNode.addSubnode(self.contextSourceNode)
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
self.addSubnode(self.containerNode)
self.addSubnode(self.maskNode)
self.contextSourceNode.contentNode.clipsToBounds = true
self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode)
self.addSubnode(self.activateArea)
self.containerNode.isGestureEnabled = false
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let strongSelf = self, let theme = strongSelf.theme else {
return
}
if isExtracted {
strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 52.0, color: theme.list.plainBackgroundColor)
}
if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect {
let rect = isExtracted ? extractedRect : nonExtractedRect
transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect)
}
transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
if !isExtracted {
self?.extractedBackgroundImageNode.image = nil
}
})
}
}
override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.tapActionAtPoint = { _ in
return .waitForSingleTap
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
strongSelf.updateTouchesAtPoint(point)
}
self.view.addGestureRecognizer(recognizer)
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
self.item?.action()
case .longTap:
break
default:
break
}
}
default:
break
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenPersonalChannelItem else {
return 50.0
}
self.item = item
self.presentationData = presentationData
self.theme = presentationData.theme
self.selectionNode.pressed = { [weak self] in
if let strongSelf = self {
strongSelf.item?.action()
}
}
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let chatListPresentationData = ChatListPresentationData(
theme: presentationData.theme,
fontSize: presentationData.listsFontSize,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
nameSortOrder: presentationData.nameSortOrder,
nameDisplayOrder: presentationData.nameDisplayOrder,
disableAnimations: false
)
let chatListNodeInteraction = ChatListNodeInteraction(
context: item.context,
animationCache: item.context.animationCache,
animationRenderer: item.context.animationRenderer,
activateSearch: {
},
peerSelected: { _, _, _, _, _ in
},
disabledPeerSelected: { _, _, _ in
},
togglePeerSelected: { _, _ in
},
togglePeersSelection: { _, _ in
},
additionalCategorySelected: { _ in
},
messageSelected: { _, _, _, _ in
},
groupSelected: { _ in
},
addContact: { _ in
},
setPeerIdWithRevealedOptions: { _, _ in
},
setItemPinned: { _, _ in
},
setPeerMuted: { _, _ in
},
setPeerThreadMuted: { _, _, _ in
},
deletePeer: { _, _ in
},
deletePeerThread: { _, _ in
},
setPeerThreadStopped: { _, _, _ in
},
setPeerThreadPinned: { _, _, _ in
},
setPeerThreadHidden: { _, _, _ in
},
updatePeerGrouping: { _, _ in
},
togglePeerMarkedUnread: { _, _ in
},
toggleArchivedFolderHiddenByDefault: {
},
toggleThreadsSelection: { _, _ in
},
hidePsa: { _ in
},
activateChatPreview: nil,
present: { _ in
},
openForumThread: { _, _ in
},
openStorageManagement: {
},
openPasswordSetup: {
},
openPremiumIntro: {
},
openPremiumGift: { _, _ in
},
openPremiumManagement: {
},
openActiveSessions: {
},
openBirthdaySetup: {
},
performActiveSessionAction: { _, _ in
},
openChatFolderUpdates: {
},
hideChatFolderUpdates: {
},
openStories: { [weak self] _, sourceNode in
guard let self, let item = self.item else {
return
}
guard let itemNode = self.itemNode as? ChatListItemNode else {
return
}
guard let controller = item.controller() else {
return
}
StoryContainerScreen.openPeerStories(context: item.context, peerId: item.data.peer.peerId, parentController: controller, avatarNode: itemNode.avatarNode)
},
openStarsTopup: { _ in
},
dismissNotice: { _ in
},
editPeer: { _ in
},
openWebApp: { _ in
},
openPhotoSetup: {
},
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
},
openUrl: { _ in
}
)
let index: EngineChatList.Item.Index
let messages: [EngineMessage]
let isLoading = item.data.isLoading
if !isLoading, !item.data.topMessages.isEmpty {
index = EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: item.data.topMessages[0].index))
messages = item.data.topMessages
} else {
index = EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: MessageIndex(id: MessageId(peerId: item.data.peer.peerId, namespace: Namespaces.Message.Cloud, id: 1), timestamp: 0)))
messages = []
}
let chatListItem = ChatListItem(
presentationData: chatListPresentationData,
context: item.context,
chatListLocation: .chatList(groupId: .root),
filterData: nil,
index: index,
content: .peer(ChatListItemContent.PeerData(
messages: messages,
peer: item.data.peer,
threadInfo: nil,
combinedReadState: nil,
isRemovedFromTotalUnreadCount: false,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
draftState: nil,
mediaDraftContentType: nil,
inputActivities: nil,
promoInfo: nil,
ignoreUnreadBadge: false,
displayAsMessage: false,
hasFailedMessages: false,
forumTopicData: nil,
topForumTopicItems: [],
autoremoveTimeout: nil,
storyState: item.data.storyStats.flatMap { storyStats in
return ChatListItemContent.StoryState(
stats: storyStats,
hasUnseenCloseFriends: false
)
},
requiresPremiumForMessaging: false,
displayAsTopicList: false,
tags: [],
customMessageListData: ChatListItemContent.CustomMessageListData(
commandPrefix: nil,
searchQuery: nil,
messageCount: nil,
hideSeparator: true,
hideDate: isLoading,
hidePeerStatus: false
)
)),
editing: false,
hasActiveRevealControls: false,
selected: false,
header: nil,
enabledContextActions: nil,
hiddenOffset: false,
interaction: chatListNodeInteraction
)
var itemNode: ListViewItemNode?
let params = ListViewItemLayoutParams(width: width - safeInsets.left - safeInsets.right - 4.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 1000.0)
if let current = self.itemNode {
itemNode = current
chatListItem.updateNode(
async: { f in f () },
node: {
return current
},
params: params,
previousItem: nil,
nextItem: nil, animation: .None,
completion: { layout, apply in
let nodeFrame = CGRect(origin: current.frame.origin, size: CGSize(width: layout.size.width, height: layout.size.height))
current.contentSize = layout.contentSize
current.insets = layout.insets
current.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var outItemNode: ListViewItemNode?
chatListItem.nodeConfiguredForParams(
async: { f in f() },
params: params,
synchronousLoads: true,
previousItem: nil,
nextItem: nil,
completion: { node, apply in
outItemNode = node
apply().1(ListViewItemApply(isOnScreen: true))
}
)
itemNode = outItemNode
}
var height = itemNode?.contentSize.height ?? 50.0
if self.itemNode !== itemNode {
self.itemNode?.removeFromSupernode()
self.itemNode = itemNode
if let itemNode {
itemNode.isUserInteractionEnabled = false
self.contextSourceNode.contentNode.addSubnode(itemNode)
}
}
let itemFrame = CGRect(origin: CGPoint(x: safeInsets.left, y: 4.0), size: CGSize(width: width - safeInsets.left - safeInsets.right - 4.0, height: height))
if let itemNode = self.itemNode {
itemNode.frame = itemFrame
}
if let itemNode = self.itemNode, item.data.isLoading {
let loadingOverlayNode: LoadingOverlayNode
if let current = self.loadingOverlayNode {
loadingOverlayNode = current
} else {
loadingOverlayNode = LoadingOverlayNode()
self.loadingOverlayNode = loadingOverlayNode
itemNode.supernode?.insertSubnode(loadingOverlayNode, aboveSubnode: itemNode)
}
loadingOverlayNode.frame = itemFrame
loadingOverlayNode.update(
context: item.context,
size: itemFrame.size,
isInlineMode: false,
presentationData: presentationData,
transition: .immediate
)
} else {
if let loadingOverlayNode = self.loadingOverlayNode {
self.loadingOverlayNode = nil
loadingOverlayNode.removeFromSupernode()
}
}
height += 8.0
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height))
let contentSize = CGSize(width: width, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: contentSize)
transition.updateFrame(node: self.contextSourceNode.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize))
let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: contentSize.height))
let extractedRect = nonExtractedRect
self.extractedRect = extractedRect
self.nonExtractedRect = nonExtractedRect
if self.contextSourceNode.isExtractedToContextPreview {
self.extractedBackgroundImageNode.frame = extractedRect
} else {
self.extractedBackgroundImageNode.frame = nonExtractedRect
}
self.contextSourceNode.contentRect = extractedRect
return height
}
private func updateTouchesAtPoint(_ point: CGPoint?) {
var isHighlighted = false
if let point, let itemNode = self.itemNode as? ChatListItemNode {
if !itemNode.avatarNode.view.convert(itemNode.avatarNode.view.bounds, to: self.view).contains(point) {
isHighlighted = true
} else if let item = self.item, item.data.storyStats == nil {
isHighlighted = true
}
}
if isHighlighted {
self.selectionNode.updateIsHighlighted(true)
} else {
self.selectionNode.updateIsHighlighted(false)
}
}
}
@@ -0,0 +1,65 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
final class PeerInfoScreenSelectableBackgroundNode: ASDisplayNode {
private let backgroundNode: ASDisplayNode
private let button: HighlightTrackingButton
let bringToFrontForHighlight: () -> Void
private var isHighlighted: Bool = false
var pressed: (() -> Void)? {
didSet {
self.button.isUserInteractionEnabled = self.pressed != nil
}
}
init(bringToFrontForHighlight: @escaping () -> Void) {
self.bringToFrontForHighlight = bringToFrontForHighlight
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.alpha = 0.0
self.button = HighlightTrackingButton()
self.button.isAccessibilityElement = false
super.init()
self.addSubnode(self.backgroundNode)
self.view.addSubview(self.button)
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
self.button.highligthedChanged = { [weak self] highlighted in
self?.updateIsHighlighted(highlighted)
}
}
@objc private func buttonPressed() {
self.pressed?()
}
func updateIsHighlighted(_ isHighlighted: Bool) {
if self.isHighlighted != isHighlighted {
self.isHighlighted = isHighlighted
if isHighlighted {
self.bringToFrontForHighlight()
self.backgroundNode.layer.removeAnimation(forKey: "opacity")
self.backgroundNode.alpha = 1.0
} else {
self.backgroundNode.alpha = 0.0
self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25)
}
}
}
func update(size: CGSize, theme: PresentationTheme, transition: ContainedViewLayoutTransition) {
self.backgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
transition.updateFrame(view: self.button, frame: CGRect(origin: CGPoint(), size: size))
}
}
@@ -0,0 +1,231 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AppBundle
import AccountContext
final class PeerInfoScreenSwitchItem: PeerInfoScreenItem {
let id: AnyHashable
let text: String
let value: Bool
let icon: UIImage?
let isLocked: Bool
let toggled: ((Bool) -> Void)?
init(id: AnyHashable, text: String, value: Bool, icon: UIImage? = nil, isLocked: Bool = false, toggled: ((Bool) -> Void)?) {
self.id = id
self.text = text
self.value = value
self.icon = icon
self.isLocked = isLocked
self.toggled = toggled
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenSwitchItemNode()
}
}
private final class PeerInfoScreenSwitchItemNode: PeerInfoScreenItemNode {
private let selectionNode: PeerInfoScreenSelectableBackgroundNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let textNode: ImmediateTextNode
private let switchNode: SwitchNode
private var lockedIconNode: ASImageNode?
private let bottomSeparatorNode: ASDisplayNode
private var lockedButtonNode: HighlightableButtonNode?
private let activateArea: AccessibilityAreaNode
private var item: PeerInfoScreenSwitchItem?
private var theme: PresentationTheme?
override init() {
var bringToFrontForHighlightImpl: (() -> Void)?
self.selectionNode = PeerInfoScreenSelectableBackgroundNode(bringToFrontForHighlight: { bringToFrontForHighlightImpl?() })
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displaysAsynchronously = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.switchNode = SwitchNode()
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init()
bringToFrontForHighlightImpl = { [weak self] in
self?.bringToFrontForHighlight?()
}
self.addSubnode(self.bottomSeparatorNode)
self.addSubnode(self.selectionNode)
self.addSubnode(self.maskNode)
self.addSubnode(self.textNode)
self.addSubnode(self.switchNode)
self.addSubnode(self.activateArea)
self.switchNode.valueUpdated = { [weak self] value in
self?.item?.toggled?(value)
}
self.activateArea.activate = { [weak self] in
guard let strongSelf = self, let item = strongSelf.item else {
return false
}
let value = !strongSelf.switchNode.isOn
item.toggled?(value)
return true
}
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenSwitchItem else {
return 10.0
}
let firstTime = self.item == nil
var updateLockedIconImage = false
if item.isLocked {
let lockedIconNode: ASImageNode
if let current = self.lockedIconNode {
lockedIconNode = current
} else {
updateLockedIconImage = true
lockedIconNode = ASImageNode()
self.lockedIconNode = lockedIconNode
self.insertSubnode(lockedIconNode, aboveSubnode: self.switchNode)
}
} else if let lockedIconNode = self.lockedIconNode {
self.lockedIconNode = nil
lockedIconNode.removeFromSupernode()
}
if item.isLocked {
self.switchNode.isUserInteractionEnabled = false
if self.lockedButtonNode == nil {
let lockedButtonNode = HighlightableButtonNode()
self.lockedButtonNode = lockedButtonNode
self.insertSubnode(lockedButtonNode, aboveSubnode: self.switchNode)
lockedButtonNode.addTarget(self, action: #selector(self.lockedButtonPressed), forControlEvents: .touchUpInside)
}
} else {
if let lockedButtonNode = self.lockedButtonNode {
self.lockedButtonNode = nil
lockedButtonNode.removeFromSupernode()
}
self.switchNode.isUserInteractionEnabled = true
}
if self.theme !== presentationData.theme {
self.theme = presentationData.theme
self.switchNode.frameColor = presentationData.theme.list.itemSwitchColors.frameColor
self.switchNode.contentColor = presentationData.theme.list.itemSwitchColors.contentColor
self.switchNode.handleColor = presentationData.theme.list.itemSwitchColors.handleColor
updateLockedIconImage = true
}
if updateLockedIconImage, let lockedIconNode = self.lockedIconNode, let image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: presentationData.theme.list.itemSecondaryTextColor) {
lockedIconNode.image = image
}
self.item = item
self.selectionNode.pressed = nil
let sideInset: CGFloat = 16.0 + safeInsets.left
let leftInset = (item.icon == nil ? sideInset : sideInset + 29.0 + 16.0)
let rightInset: CGFloat = 56.0 + safeAreaInsets.right
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let textColorValue: UIColor = presentationData.theme.list.itemPrimaryTextColor
self.textNode.maximumNumberOfLines = 1
self.textNode.attributedText = NSAttributedString(string: item.text, font: Font.regular(17.0), textColor: textColorValue)
self.activateArea.accessibilityLabel = item.text
self.activateArea.accessibilityValue = item.value ? presentationData.strings.VoiceOver_Common_On : presentationData.strings.VoiceOver_Common_Off
self.activateArea.accessibilityHint = presentationData.strings.VoiceOver_Common_SwitchHint
let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset, height: .greatestFiniteMagnitude))
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: 16.0), size: textSize)
let height = textSize.height + 32.0
if let icon = item.icon {
if self.iconNode.supernode == nil {
self.addSubnode(self.iconNode)
}
self.iconNode.image = icon
let iconFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((height - icon.size.height) / 2.0)), size: icon.size)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
} else if self.iconNode.supernode != nil {
self.iconNode.image = nil
self.iconNode.removeFromSupernode()
}
transition.updateFrame(node: self.textNode, frame: textFrame)
if let switchView = self.switchNode.view as? UISwitch {
if self.switchNode.bounds.size.width.isZero {
switchView.sizeToFit()
}
let switchSize = switchView.bounds.size
let switchFrame = CGRect(origin: CGPoint(x: width - switchSize.width - 15.0 - safeInsets.right, y: floor((height - switchSize.height) / 2.0)), size: switchSize)
self.switchNode.frame = switchFrame
if switchView.isOn != item.value {
switchView.setOn(item.value, animated: !firstTime)
}
self.lockedButtonNode?.frame = switchFrame
if let lockedIconNode = self.lockedIconNode, let icon = lockedIconNode.image {
lockedIconNode.frame = CGRect(origin: CGPoint(x: switchFrame.minX + 16.0 - UIScreenPixel, y: switchFrame.minY + 8.0), size: icon.size)
}
}
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
self.bottomSeparatorNode.isHidden = hasBottomCorners
let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel
self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition)
transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset)))
let separatorRightInset: CGFloat = 16.0
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset - separatorRightInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height))
return height
}
@objc private func lockedButtonPressed() {
self.item?.toggled?(self.switchNode.isOn)
}
}
@@ -0,0 +1,249 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import AccountContext
import ContextUI
import PhotoResources
import TelegramUIPreferences
import ItemListPeerItem
import MergeLists
import ItemListUI
import ChatControllerInteraction
import PeerInfoVisualMediaPaneNode
import PeerInfoPaneNode
private struct GroupsInCommonListTransaction {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
}
private struct GroupsInCommonListEntry: Comparable, Identifiable {
var index: Int
var peer: Peer
var stableId: PeerId {
return self.peer.id
}
static func ==(lhs: GroupsInCommonListEntry, rhs: GroupsInCommonListEntry) -> Bool {
return lhs.peer.isEqual(rhs.peer)
}
static func <(lhs: GroupsInCommonListEntry, rhs: GroupsInCommonListEntry) -> Bool {
return lhs.index < rhs.index
}
func item(context: AccountContext, presentationData: PresentationData, openPeer: @escaping (Peer) -> Void, openPeerContextAction: @escaping (Peer, ASDisplayNode, ContextGesture?) -> Void) -> ListViewItem {
let peer = self.peer
return ItemListPeerItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: EnginePeer(self.peer), presence: nil, text: .none, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, selectable: true, sectionId: 0, action: {
openPeer(peer)
}, setPeerIdWithRevealedOptions: { _, _ in
}, removePeer: { _ in
}, contextAction: { node, gesture in
openPeerContextAction(peer, node, gesture)
}, hasTopStripe: false, noInsets: true, noCorners: true, style: .plain)
}
}
private func preparedTransition(from fromEntries: [GroupsInCommonListEntry], to toEntries: [GroupsInCommonListEntry], context: AccountContext, presentationData: PresentationData, openPeer: @escaping (Peer) -> Void, openPeerContextAction: @escaping (Peer, ASDisplayNode, ContextGesture?) -> Void) -> GroupsInCommonListTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, openPeer: openPeer, openPeerContextAction: openPeerContextAction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, openPeer: openPeer, openPeerContextAction: openPeerContextAction), directionHint: nil) }
return GroupsInCommonListTransaction(deletions: deletions, insertions: insertions, updates: updates)
}
final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode {
private let context: AccountContext
private let peerId: PeerId
private let chatControllerInteraction: ChatControllerInteraction
private let openPeerContextAction: (Bool, Peer, ASDisplayNode, ContextGesture?) -> Void
private let groupsInCommonContext: GroupsInCommonContext
weak var parentController: ViewController?
private let listNode: ListView
private var state: GroupsInCommonState?
private var currentEntries: [GroupsInCommonListEntry] = []
private var enqueuedTransactions: [GroupsInCommonListTransaction] = []
private var currentParams: (size: CGSize, isScrollingLockedAtTop: Bool, presentationData: PresentationData)?
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
var status: Signal<PeerInfoStatusData?, NoError> {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
return self.groupsInCommonContext.state
|> map { state in
if let count = state.count {
return PeerInfoStatusData(text: presentationData.strings.SharedMedia_CommonGroupCount(Int32(count)), isActivity: false, key: .groupsInCommon)
} else {
return nil
}
}
}
var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
var tabBarOffset: CGFloat {
return 0.0
}
private var disposable: Disposable?
init(context: AccountContext, peerId: PeerId, chatControllerInteraction: ChatControllerInteraction, openPeerContextAction: @escaping (Bool, Peer, ASDisplayNode, ContextGesture?) -> Void, groupsInCommonContext: GroupsInCommonContext) {
self.context = context
self.peerId = peerId
self.chatControllerInteraction = chatControllerInteraction
self.openPeerContextAction = openPeerContextAction
self.groupsInCommonContext = groupsInCommonContext
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.listNode = ListView()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
super.init()
self.listNode.preloadPages = true
self.addSubnode(self.listNode)
self.disposable = (groupsInCommonContext.state
|> deliverOnMainQueue).startStrict(next: { [weak self] state in
guard let strongSelf = self else {
return
}
strongSelf.state = state
if let (_, _, presentationData) = strongSelf.currentParams {
strongSelf.updatePeers(state: state, presentationData: presentationData)
}
})
self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in
guard let strongSelf = self, let state = strongSelf.state, case .ready(true) = state.dataState else {
return
}
if case let .known(value) = offset, value < 100.0, case .ready(true) = state.dataState {
strongSelf.groupsInCommonContext.loadMore()
}
}
}
deinit {
self.disposable?.dispose()
}
func ensureMessageIsVisible(id: MessageId) {
}
func scrollToTop() -> Bool {
if !self.listNode.scrollToOffsetFromTop(0.0, animated: true) {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
return true
} else {
return false
}
}
func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
let isFirstLayout = self.currentParams == nil
self.currentParams = (size, isScrollingLockedAtTop, presentationData)
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size))
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
var scrollToItem: ListViewScrollToItem?
if isScrollingLockedAtTop {
switch self.listNode.visibleContentOffset() {
case let .known(value) where value <= CGFloat.ulpOfOne:
break
default:
scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Spring(duration: duration), directionHint: .Up)
}
}
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: scrollToItem, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.listNode.scrollEnabled = !isScrollingLockedAtTop
if isFirstLayout, let state = self.state {
self.updatePeers(state: state, presentationData: presentationData)
}
}
private func updatePeers(state: GroupsInCommonState, presentationData: PresentationData) {
var entries: [GroupsInCommonListEntry] = []
for peer in state.peers {
if let peer = peer.peer {
entries.append(GroupsInCommonListEntry(index: entries.count, peer: peer))
}
}
let transaction = preparedTransition(from: self.currentEntries, to: entries, context: self.context, presentationData: presentationData, openPeer: { [weak self] peer in
self?.chatControllerInteraction.openPeer(EnginePeer(peer), .default, nil, .default)
}, openPeerContextAction: { [weak self] peer, node, gesture in
self?.openPeerContextAction(false, peer, node, gesture)
})
self.currentEntries = entries
self.enqueuedTransactions.append(transaction)
self.dequeueTransaction()
}
private func dequeueTransaction() {
guard let _ = self.currentParams, let transaction = self.enqueuedTransactions.first else {
return
}
self.enqueuedTransactions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.Synchronous)
self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
if !strongSelf.didSetReady {
strongSelf.didSetReady = true
strongSelf.ready.set(.single(true))
}
})
}
func findLoadedMessage(id: MessageId) -> Message? {
return nil
}
func updateHiddenMedia() {
}
func transferVelocity(_ velocity: CGFloat) {
if velocity > 0.0 {
self.listNode.transferVelocity(velocity)
}
}
func cancelPreviewGestures() {
}
func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func addToTransitionSurface(view: UIView) {
}
func updateSelectedMessages(animated: Bool) {
}
}
@@ -0,0 +1,528 @@
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import PresentationDataUtils
import AccountContext
import ContextUI
import PhotoResources
import TelegramUIPreferences
import UniversalMediaPlayer
import TelegramBaseController
import OverlayStatusController
import ListMessageItem
import UndoUI
import ChatPresentationInterfaceState
import ChatControllerInteraction
import PeerInfoVisualMediaPaneNode
import ChatMessageItemView
import PeerInfoPaneNode
final class PeerInfoListPaneNode: ASDisplayNode, PeerInfoPaneNode {
private let context: AccountContext
private let peerId: PeerId
private let chatLocation: ChatLocation
private let chatLocationContextHolder: Atomic<ChatLocationContextHolder?>
private let chatControllerInteraction: ChatControllerInteraction
weak var parentController: ViewController?
private let listNode: ChatHistoryListNode
private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)?
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
private let selectedMessagesPromise = Promise<Set<MessageId>?>(nil)
private var selectedMessages: Set<MessageId>? {
didSet {
if self.selectedMessages != oldValue {
self.selectedMessagesPromise.set(.single(self.selectedMessages))
}
}
}
private var hiddenMediaDisposable: Disposable?
private var mediaStatusDisposable: Disposable?
private var playlistPreloadDisposable: Disposable?
private var playlistStateAndType: (SharedMediaPlaylistItem, SharedMediaPlaylistItem?, SharedMediaPlaylistItem?, MusicPlaybackSettingsOrder, MediaManagerPlayerType, Account)?
private var playlistLocation: SharedMediaPlaylistLocation?
private var mediaAccessoryPanelContainer: PassthroughContainerNode
private var mediaAccessoryPanel: (MediaNavigationAccessoryPanel, MediaManagerPlayerType)?
private var dismissingPanel: ASDisplayNode?
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
var status: Signal<PeerInfoStatusData?, NoError> {
self.statusPromise.get()
}
var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
var tabBarOffset: CGFloat {
return 0.0
}
init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, chatControllerInteraction: ChatControllerInteraction, peerId: PeerId, chatLocation: ChatLocation, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>, tagMask: MessageTags) {
self.context = context
self.peerId = peerId
self.chatLocation = chatLocation
self.chatLocationContextHolder = chatLocationContextHolder
self.chatControllerInteraction = chatControllerInteraction
self.selectedMessages = chatControllerInteraction.selectionState.flatMap { $0.selectedIds }
self.selectedMessagesPromise.set(.single(self.selectedMessages))
self.listNode = context.sharedContext.makeChatHistoryListNode(context: context, updatedPresentationData: updatedPresentationData ?? (context.sharedContext.currentPresentationData.with({ $0 }), context.sharedContext.presentationData), chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, tag: .tag(tagMask), source: .default, subject: nil, controllerInteraction: chatControllerInteraction, selectedMessages: self.selectedMessagesPromise.get(), mode: .list(search: false, reversed: false, reverseGroups: false, displayHeaders: .allButLast, hintLinks: tagMask == .webPage, isGlobalSearch: false))
self.listNode.clipsToBounds = true
self.listNode.defaultToSynchronousTransactionWhileScrolling = true
self.listNode.scroller.bounces = false
self.mediaAccessoryPanelContainer = PassthroughContainerNode()
self.mediaAccessoryPanelContainer.clipsToBounds = true
super.init()
self.listNode.preloadPages = true
self.addSubnode(self.listNode)
self.addSubnode(self.mediaAccessoryPanelContainer)
self.ready.set(self.listNode.historyState.get()
|> take(1)
|> map { _ -> Bool in true })
if [.file, .music, .voiceOrInstantVideo].contains(tagMask) {
self.mediaStatusDisposable = (context.sharedContext.mediaManager.globalMediaPlayerState
|> mapToSignal { playlistStateAndType -> Signal<(Account, SharedMediaPlayerItemPlaybackState, MediaManagerPlayerType)?, NoError> in
if let (account, state, type) = playlistStateAndType {
switch state {
case let .state(state):
if let playlistId = state.playlistId as? PeerMessagesMediaPlaylistId, case .peer(peerId) = playlistId {
switch type {
case .voice:
if tagMask != .voiceOrInstantVideo {
return .single(nil) |> delay(0.2, queue: .mainQueue())
}
case .music:
if tagMask != .music {
return .single(nil) |> delay(0.2, queue: .mainQueue())
}
case .file:
if tagMask != .file {
return .single(nil) |> delay(0.2, queue: .mainQueue())
}
}
return .single((account, state, type))
} else {
return .single(nil) |> delay(0.2, queue: .mainQueue())
}
case .loading:
return .single(nil) |> delay(0.2, queue: .mainQueue())
}
} else {
return .single(nil)
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] playlistStateAndType in
guard let strongSelf = self else {
return
}
if !arePlaylistItemsEqual(strongSelf.playlistStateAndType?.0, playlistStateAndType?.1.item) ||
!arePlaylistItemsEqual(strongSelf.playlistStateAndType?.1, playlistStateAndType?.1.previousItem) ||
!arePlaylistItemsEqual(strongSelf.playlistStateAndType?.2, playlistStateAndType?.1.nextItem) ||
strongSelf.playlistStateAndType?.3 != playlistStateAndType?.1.order || strongSelf.playlistStateAndType?.4 != playlistStateAndType?.2 {
if let playlistStateAndType = playlistStateAndType {
strongSelf.playlistStateAndType = (playlistStateAndType.1.item, playlistStateAndType.1.previousItem, playlistStateAndType.1.nextItem, playlistStateAndType.1.order, playlistStateAndType.2, playlistStateAndType.0)
strongSelf.playlistLocation = playlistStateAndType.1.playlistLocation
} else {
strongSelf.playlistStateAndType = nil
strongSelf.playlistLocation = nil
}
if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = strongSelf.currentParams {
strongSelf.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: true, transition: .animated(duration: 0.4, curve: .spring))
}
}
})
}
self.statusPromise.set(context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Messages.MessageCount(peerId: peerId, threadId: chatLocation.threadId, tag: tagMask)
)
|> map { count -> PeerInfoStatusData? in
let count: Int = count ?? 0
if count == 0 {
return nil
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch tagMask {
case MessageTags.file:
return PeerInfoStatusData(text: presentationData.strings.SharedMedia_FileCount(Int32(count)), isActivity: false, key: .files)
case MessageTags.music:
return PeerInfoStatusData(text: presentationData.strings.SharedMedia_MusicCount(Int32(count)), isActivity: false, key: .music)
case MessageTags.voiceOrInstantVideo:
return PeerInfoStatusData(text: presentationData.strings.SharedMedia_VoiceMessageCount(Int32(count)), isActivity: false, key: .voice)
case MessageTags.webPage:
return PeerInfoStatusData(text: presentationData.strings.SharedMedia_LinkCount(Int32(count)), isActivity: false, key: .links)
default:
return nil
}
})
}
deinit {
self.hiddenMediaDisposable?.dispose()
self.mediaStatusDisposable?.dispose()
self.playlistPreloadDisposable?.dispose()
}
func ensureMessageIsVisible(id: MessageId) {
}
func scrollToTop() -> Bool {
let offset = self.listNode.visibleContentOffset()
switch offset {
case let .known(value) where value <= CGFloat.ulpOfOne:
return false
default:
self.listNode.scrollToEndOfHistory()
return true
}
}
func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
self.currentParams = (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData)
var topPanelHeight: CGFloat = 0.0
if let (item, previousItem, nextItem, order, type, _) = self.playlistStateAndType {
let panelHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight
topPanelHeight = floor(panelHeight * expandProgress)
let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight - panelHeight), size: CGSize(width: size.width, height: panelHeight))
if let (mediaAccessoryPanel, mediaType) = self.mediaAccessoryPanel, mediaType == type {
transition.updateFrame(layer: mediaAccessoryPanel.layer, frame: panelFrame)
mediaAccessoryPanel.updateLayout(size: panelFrame.size, leftInset: sideInset, rightInset: sideInset, isHidden: false, transition: transition)
switch order {
case .regular:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, previousItem, nextItem)
case .reversed:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, nextItem, previousItem)
case .random:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, nil, nil)
}
let delayedStatus = self.context.sharedContext.mediaManager.globalMediaPlayerState
|> mapToSignal { value -> Signal<(Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?, NoError> in
guard let value = value else {
return .single(nil)
}
switch value.1 {
case .state:
return .single(value)
case .loading:
return .single(value) |> delay(0.1, queue: .mainQueue())
}
}
mediaAccessoryPanel.containerNode.headerNode.playbackStatus = delayedStatus
|> map { state -> MediaPlayerStatus in
if let stateOrLoading = state?.1, case let .state(state) = stateOrLoading {
return state.status
} else {
return MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
}
} else {
if let (mediaAccessoryPanel, _) = self.mediaAccessoryPanel {
self.mediaAccessoryPanel = nil
self.dismissingPanel = mediaAccessoryPanel
mediaAccessoryPanel.animateOut(transition: transition, completion: { [weak self, weak mediaAccessoryPanel] in
mediaAccessoryPanel?.removeFromSupernode()
if let strongSelf = self, strongSelf.dismissingPanel === mediaAccessoryPanel {
strongSelf.dismissingPanel = nil
}
})
}
let mediaAccessoryPanel = MediaNavigationAccessoryPanel(context: self.context, presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, displayBackground: true)
mediaAccessoryPanel.containerNode.headerNode.displayScrubber = item.playbackData?.type != .instantVideo
mediaAccessoryPanel.getController = { [weak self] in
return self?.parentController
}
mediaAccessoryPanel.presentInGlobalOverlay = { [weak self] c in
self?.parentController?.presentInGlobalOverlay(c)
}
mediaAccessoryPanel.close = { [weak self] in
if let strongSelf = self, let (_, _, _, _, type, _) = strongSelf.playlistStateAndType {
strongSelf.context.sharedContext.mediaManager.setPlaylist(nil, type: type, control: SharedMediaPlayerControlAction.playback(.pause))
}
}
mediaAccessoryPanel.setRate = { [weak self] rate, changeType in
guard let strongSelf = self else {
return
}
let _ = (strongSelf.context.sharedContext.accountManager.transaction { transaction -> AudioPlaybackRate in
let settings = transaction.getSharedData(ApplicationSpecificSharedDataKeys.musicPlaybackSettings)?.get(MusicPlaybackSettings.self) ?? MusicPlaybackSettings.defaultSettings
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.musicPlaybackSettings, { _ in
return PreferencesEntry(settings.withUpdatedVoicePlaybackRate(rate))
})
return rate
}
|> deliverOnMainQueue).startStandalone(next: { baseRate in
guard let strongSelf = self, let (_, _, _, _, type, _) = strongSelf.playlistStateAndType else {
return
}
strongSelf.context.sharedContext.mediaManager.playlistControl(.setBaseRate(baseRate), type: type)
if let controller = strongSelf.parentController {
var hasTooltip = false
controller.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
hasTooltip = true
controller.dismissWithCommitAction()
}
return true
})
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let text: String?
let rate: CGFloat?
if case let .sliderCommit(previousValue, newValue) = changeType {
let value = String(format: "%0.1f", baseRate.doubleValue)
if baseRate == .x1 {
text = presentationData.strings.Conversation_AudioRateTooltipNormal
} else {
text = presentationData.strings.Conversation_AudioRateTooltipCustom(value).string
}
if newValue > previousValue {
rate = .infinity
} else if newValue < previousValue {
rate = -.infinity
} else {
rate = nil
}
} else if baseRate == .x1 {
text = presentationData.strings.Conversation_AudioRateTooltipNormal
rate = 1.0
} else if baseRate == .x1_5 {
text = presentationData.strings.Conversation_AudioRateTooltip15X
rate = 1.5
} else if baseRate == .x2 {
text = presentationData.strings.Conversation_AudioRateTooltipSpeedUp
rate = 2.0
} else {
text = nil
rate = nil
}
var showTooltip = true
if case .sliderChange = changeType {
showTooltip = false
}
if let rate, let text, showTooltip {
controller.present(
UndoOverlayController(
presentationData: presentationData,
content: .audioRate(
rate: rate,
text: text
),
elevatedLayout: false,
animateInAsReplacement: hasTooltip,
action: { action in
return true
}
),
in: .current
)
}
}
})
}
mediaAccessoryPanel.togglePlayPause = { [weak self] in
if let strongSelf = self, let (_, _, _, _, type, _) = strongSelf.playlistStateAndType {
strongSelf.context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: type)
}
}
mediaAccessoryPanel.playPrevious = { [weak self] in
if let strongSelf = self, let (_, _, _, _, type, _) = strongSelf.playlistStateAndType {
strongSelf.context.sharedContext.mediaManager.playlistControl(.next, type: type)
}
}
mediaAccessoryPanel.playNext = { [weak self] in
if let strongSelf = self, let (_, _, _, _, type, _) = strongSelf.playlistStateAndType {
strongSelf.context.sharedContext.mediaManager.playlistControl(.previous, type: type)
}
}
mediaAccessoryPanel.tapAction = { [weak self] in
guard let strongSelf = self, let _ = strongSelf.chatControllerInteraction.navigationController(), let (state, _, _, order, type, account) = strongSelf.playlistStateAndType else {
return
}
if let id = state.id as? PeerMessagesMediaPlaylistItemId, let playlistLocation = strongSelf.playlistLocation as? PeerMessagesPlaylistLocation, case let .messages(chatLocation, _, _) = playlistLocation {
if type == .music {
let signal = strongSelf.context.sharedContext.messageFromPreloadedChatHistoryViewForLocation(id: id.messageId, location: ChatHistoryLocationInput(content: .InitialSearch(subject: MessageHistoryInitialSearchSubject(location: .id(id.messageId)), count: 60, highlight: true, setupReply: false), id: 0), context: strongSelf.context, chatLocation: .peer(id: id.messageId.peerId), subject: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil), tag: .tag(MessageTags.music))
var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
self?.chatControllerInteraction.presentController(controller, nil)
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = MetaDisposable()
var progressStarted = false
strongSelf.playlistPreloadDisposable?.dispose()
strongSelf.playlistPreloadDisposable = (signal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
|> deliverOnMainQueue).startStandalone(next: { index in
guard let strongSelf = self else {
return
}
if let _ = index.0 {
let controllerContext: AccountContext
if account.id == strongSelf.context.account.id {
controllerContext = strongSelf.context
} else {
controllerContext = strongSelf.context.sharedContext.makeTempAccountContext(account: account)
}
let controller = strongSelf.context.sharedContext.makeOverlayAudioPlayerController(context: controllerContext, chatLocation: chatLocation, type: type, initialMessageId: id.messageId, initialOrder: order, playlistLocation: nil, parentNavigationController: strongSelf.chatControllerInteraction.navigationController())
strongSelf.view.window?.endEditing(true)
strongSelf.chatControllerInteraction.presentController(controller, nil)
} else if index.1 {
if !progressStarted {
progressStarted = true
progressDisposable.set(progressSignal.startStandalone())
}
}
}, completed: {
})
cancelImpl = {
self?.playlistPreloadDisposable?.dispose()
}
} else {
strongSelf.context.sharedContext.navigateToChat(accountId: strongSelf.context.account.id, peerId: id.messageId.peerId, messageId: id.messageId)
}
}
}
mediaAccessoryPanel.frame = panelFrame
if let dismissingPanel = self.dismissingPanel {
self.mediaAccessoryPanelContainer.insertSubnode(mediaAccessoryPanel, aboveSubnode: dismissingPanel)
} else {
self.mediaAccessoryPanelContainer.addSubnode(mediaAccessoryPanel)
}
self.mediaAccessoryPanel = (mediaAccessoryPanel, type)
mediaAccessoryPanel.updateLayout(size: panelFrame.size, leftInset: sideInset, rightInset: sideInset, isHidden: false, transition: .immediate)
switch order {
case .regular:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, previousItem, nextItem)
case .reversed:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, nextItem, previousItem)
case .random:
mediaAccessoryPanel.containerNode.headerNode.playbackItems = (item, nil, nil)
}
mediaAccessoryPanel.containerNode.headerNode.playbackStatus = self.context.sharedContext.mediaManager.globalMediaPlayerState
|> map { state -> MediaPlayerStatus in
if let stateOrLoading = state?.1, case let .state(state) = stateOrLoading {
return state.status
} else {
return MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
}
mediaAccessoryPanel.animateIn(transition: transition)
}
} else if let (mediaAccessoryPanel, _) = self.mediaAccessoryPanel {
self.mediaAccessoryPanel = nil
self.dismissingPanel = mediaAccessoryPanel
mediaAccessoryPanel.animateOut(transition: transition, completion: { [weak self, weak mediaAccessoryPanel] in
mediaAccessoryPanel?.removeFromSupernode()
if let strongSelf = self, strongSelf.dismissingPanel === mediaAccessoryPanel {
strongSelf.dismissingPanel = nil
}
})
}
transition.updateFrame(node: self.mediaAccessoryPanelContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: size.width, height: MediaNavigationAccessoryHeaderNode.minimizedHeight)))
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size))
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
self.listNode.updateLayout(transition: transition, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: topPanelHeight + topInset, left: sideInset, bottom: bottomInset, right: sideInset), duration: duration, curve: curve))
if isScrollingLockedAtTop {
switch self.listNode.visibleContentOffset() {
case let .known(value) where value <= CGFloat.ulpOfOne:
break
case .none:
break
default:
self.listNode.scrollToEndOfHistory()
}
}
self.listNode.scrollEnabled = !isScrollingLockedAtTop
}
func findLoadedMessage(id: MessageId) -> Message? {
self.listNode.messageInCurrentHistoryView(id)
}
func updateHiddenMedia() {
self.listNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ListMessageNode {
itemNode.updateHiddenMedia()
}
}
}
func transferVelocity(_ velocity: CGFloat) {
if velocity > 0.0 {
self.listNode.transferVelocity(velocity)
}
}
func cancelPreviewGestures() {
}
func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
var transitionNode: (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
self.listNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ListMessageNode {
if let result = itemNode.transitionNode(id: messageId, media: media, adjustRect: false) {
transitionNode = result
}
}
}
return transitionNode
}
func addToTransitionSurface(view: UIView) {
self.view.addSubview(view)
}
func updateSelectedMessages(animated: Bool) {
self.listNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ChatMessageItemView {
itemNode.updateSelectionState(animated: animated)
}
}
self.selectedMessages = self.chatControllerInteraction.selectionState.flatMap { $0.selectedIds }
}
}
@@ -0,0 +1,531 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import AccountContext
import ContextUI
import PhotoResources
import TelegramUIPreferences
import ItemListPeerItem
import ItemListPeerActionItem
import MergeLists
import ItemListUI
import PeerInfoVisualMediaPaneNode
import PeerInfoPaneNode
import ContactsPeerItem
private struct PeerMembersListTransaction {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let animated: Bool
}
enum PeerMembersListAction {
case open
case promote
case restrict
case remove
case openStories(sourceView: UIView)
}
private enum PeerMembersListEntryStableId: Hashable {
case addMember
case peer(PeerId)
}
private enum PeerMembersListEntry: Comparable, Identifiable {
case addMember(PresentationTheme, String)
case member(theme: PresentationTheme, index: Int, member: PeerInfoMember)
var stableId: PeerMembersListEntryStableId {
switch self {
case .addMember:
return .addMember
case let .member(_, _, member):
return .peer(member.id)
}
}
static func ==(lhs: PeerMembersListEntry, rhs: PeerMembersListEntry) -> Bool {
switch lhs {
case let .addMember(lhsTheme, lhsText):
if case let .addMember(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .member(lhsTheme, lhsIndex, lhsMember):
if case let .member(rhsTheme, rhsIndex, rhsMember) = rhs, lhsTheme === rhsTheme, lhsIndex == rhsIndex, lhsMember == rhsMember {
return true
} else {
return false
}
}
}
static func <(lhs: PeerMembersListEntry, rhs: PeerMembersListEntry) -> Bool {
switch lhs {
case .addMember:
switch rhs {
case .addMember:
return false
case .member:
return true
}
case let .member(_, lhsIndex, _):
switch rhs {
case .addMember:
return false
case let .member(_, rhsIndex, _):
return lhsIndex < rhsIndex
}
}
}
func item(context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem {
switch self {
case let .addMember(_, text):
return ItemListPeerActionItem(presentationData: ItemListPresentationData(presentationData), icon: PresentationResourcesItemList.addPersonIcon(presentationData.theme), title: text, alwaysPlain: true, sectionId: 0, height: .compactPeerList, color: .accent, editing: false, action: {
addMemberAction()
})
case let .member(_, _, member):
let label: String?
if let rank = member.rank {
label = rank
} else {
switch member.role {
case .creator:
label = presentationData.strings.GroupInfo_LabelOwner
case .admin:
label = presentationData.strings.GroupInfo_LabelAdmin
case .member:
label = nil
}
}
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: enclosingPeer, member: member)
var options: [ItemListPeerItemRevealOption] = []
if actions.contains(.promote) && enclosingPeer is TelegramChannel{
options.append(ItemListPeerItemRevealOption(type: .neutral, title: presentationData.strings.GroupInfo_ActionPromote, action: {
action(member, .promote)
}))
}
if actions.contains(.restrict) {
if enclosingPeer is TelegramChannel {
options.append(ItemListPeerItemRevealOption(type: .warning, title: presentationData.strings.GroupInfo_ActionRestrict, action: {
action(member, .restrict)
}))
}
options.append(ItemListPeerItemRevealOption(type: .destructive, title: presentationData.strings.Common_Delete, action: {
action(member, .remove)
}))
}
var presence: EnginePeer.Presence
if member.peer.id == context.account.peerId {
presence = EnginePeer.Presence(status: .present(until: Int32.max), lastActivity: 0)
} else if let value = member.presence {
presence = EnginePeer.Presence(value)
} else {
presence = EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0)
}
var status: ContactsPeerItemStatus = .presence(presence, presentationData.dateTimeFormat)
if let user = member.peer as? TelegramUser, let botInfo = user.botInfo {
let botStatus: String
if botInfo.flags.contains(.hasAccessToChatHistory) {
botStatus = presentationData.strings.Bot_GroupStatusReadsHistory
} else {
botStatus = presentationData.strings.Bot_GroupStatusDoesNotReadHistory
}
status = .custom(string: NSAttributedString(string: botStatus, font: Font.regular(floor(presentationData.listsFontSize.itemListBaseFontSize * 14.0 / 17.0)), textColor: presentationData.theme.list.itemSecondaryTextColor), multiline: false, isActive: false, icon: nil)
}
return ContactsPeerItem(
presentationData: ItemListPresentationData(presentationData),
style: .plain,
sectionId: 0,
sortOrder: presentationData.nameSortOrder,
displayOrder: presentationData.nameDisplayOrder,
context: context,
peerMode: .memberList,
peer: .peer(peer: EnginePeer(member.peer), chatPeer: EnginePeer(member.peer)),
status: status,
rightLabelText: label,
enabled: true,
selection: .none,
editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false),
options: options,
additionalActions: [],
actionIcon: .none,
index: nil,
header: nil,
action: member.peer.id == context.account.peerId ? nil : { _ in
action(member, .open)
},
disabledAction: nil,
setPeerIdWithRevealedOptions: { _, _ in
},
deletePeer: nil,
itemHighlighting: nil,
contextAction: contextAction == nil ? nil : { node, gesture, _ in
contextAction?(member, node, gesture)
},
animationCache: context.animationCache,
animationRenderer: context.animationRenderer,
storyStats: member.storyStats.flatMap { storyStats in
return (
total: storyStats.totalCount,
unseen: storyStats.unseenCount,
hasUnseenCloseFriends: storyStats.hasUnseenCloseFriends,
hasLiveItems: storyStats.hasLiveItems
)
},
openStories: { _, sourceNode in
action(member, .openStories(sourceView: sourceNode.view))
}
)
/*return ItemListPeerItem(
presentationData: ItemListPresentationData(presentationData),
dateTimeFormat: presentationData.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
context: context,
peer: EnginePeer(member.peer),
presence: member.presence.flatMap(EnginePeer.Presence.init),
text: .presence,
label: label == nil ? .none : .text(label!, .standard),
editing: ItemListPeerItemEditing(editable: !options.isEmpty, editing: false, revealed: false),
revealOptions: ItemListPeerItemRevealOptions(options: options),
switchValue: nil,
enabled: true,
selectable: member.id != context.account.peerId,
sectionId: 0,
action: {
action(member, .open)
},
setPeerIdWithRevealedOptions: { _, _ in
},
removePeer: { _ in
},
contextAction: contextAction == nil ? nil : { node, gesture in
contextAction?(member, node, gesture)
},
hasTopStripe: false,
noInsets: true,
noCorners: true,
disableInteractiveTransitionIfNecessary: true,
storyStats: member.storyStats,
openStories: { sourceView in
action(member, .openStories(sourceView: sourceView))
}
)*/
}
}
}
private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toEntries: [PeerMembersListEntry], context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> PeerMembersListTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enclosingPeer: enclosingPeer, addMemberAction: addMemberAction, action: action, contextAction: contextAction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enclosingPeer: enclosingPeer, addMemberAction: addMemberAction, action: action, contextAction: contextAction), directionHint: nil) }
return PeerMembersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count)
}
final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode {
private let context: AccountContext
private let membersContext: PeerInfoMembersContext
private let addMemberAction: () -> Void
private let action: (PeerInfoMember, PeerMembersListAction) -> Void
weak var parentController: ViewController?
private let listNode: ListView
private var currentEntries: [PeerMembersListEntry] = []
private var enclosingPeer: Peer?
private var currentState: PeerInfoMembersState?
private var canLoadMore: Bool = false
private var enqueuedTransactions: [PeerMembersListTransaction] = []
private var currentParams: (size: CGSize, isScrollingLockedAtTop: Bool)?
private let presentationDataPromise = Promise<PresentationData>()
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
var status: Signal<PeerInfoStatusData?, NoError> {
return .single(nil)
}
var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
var tabBarOffset: CGFloat {
return 0.0
}
private var disposable: Disposable?
init(context: AccountContext, peerId: PeerId, membersContext: PeerInfoMembersContext, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void) {
self.context = context
self.membersContext = membersContext
self.addMemberAction = addMemberAction
self.action = action
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.listNode = ListView()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
super.init()
self.listNode.preloadPages = true
self.addSubnode(self.listNode)
self.disposable = (combineLatest(queue: .mainQueue(),
membersContext.state,
self.presentationDataPromise.get(),
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] state, presentationData, enclosingPeer in
guard let strongSelf = self, let enclosingPeer = enclosingPeer else {
return
}
strongSelf.enclosingPeer = enclosingPeer._asPeer()
strongSelf.currentState = state
strongSelf.updateState(enclosingPeer: enclosingPeer._asPeer(), state: state, presentationData: presentationData)
})
self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in
guard let strongSelf = self, let state = strongSelf.currentState, case .ready(true) = state.dataState else {
return
}
if case let .known(value) = offset, value < 100.0 {
strongSelf.membersContext.loadMore()
}
}
}
deinit {
self.disposable?.dispose()
}
func ensureMessageIsVisible(id: MessageId) {
}
func scrollToTop() -> Bool {
if !self.listNode.scrollToOffsetFromTop(0.0, animated: true) {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
return true
} else {
return false
}
}
func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
let isFirstLayout = self.currentParams == nil
self.currentParams = (size, isScrollingLockedAtTop)
self.presentationDataPromise.set(.single(presentationData))
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size))
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
var scrollToItem: ListViewScrollToItem?
if isScrollingLockedAtTop {
switch self.listNode.visibleContentOffset() {
case let .known(value) where value <= CGFloat.ulpOfOne:
break
default:
scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Spring(duration: duration), directionHint: .Up)
}
}
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: scrollToItem, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.listNode.scrollEnabled = !isScrollingLockedAtTop
if isFirstLayout, let enclosingPeer = self.enclosingPeer, let state = self.currentState {
self.updateState(enclosingPeer: enclosingPeer, state: state, presentationData: presentationData)
}
}
private func updateState(enclosingPeer: Peer, state: PeerInfoMembersState, presentationData: PresentationData) {
var entries: [PeerMembersListEntry] = []
if state.canAddMembers {
entries.append(.addMember(presentationData.theme, presentationData.strings.GroupInfo_AddParticipant))
}
for member in state.members {
entries.append(.member(theme: presentationData.theme, index: entries.count, member: member))
}
let transaction = preparedTransition(from: self.currentEntries, to: entries, context: self.context, presentationData: presentationData, enclosingPeer: enclosingPeer, addMemberAction: { [weak self] in
self?.addMemberAction()
}, action: { [weak self] member, action in
guard let self else {
return
}
if case .open = action {
self.listNode.clearHighlightAnimated(true)
}
self.action(member, action)
}, contextAction: { [weak self] member, sourceNode, gesture in
guard let self else {
return
}
var node: ContextExtractedContentContainingNode?
if let sourceNode = sourceNode as? ContextExtractedContentContainingNode {
node = sourceNode
} else {
for subnode in sourceNode.subnodes ?? [] {
if let subnode = subnode as? ContextExtractedContentContainingNode {
node = subnode
break
}
}
}
guard let node else {
gesture?.cancel()
return
}
let actions = availableActionsForMemberOfPeer(accountPeerId: self.context.account.peerId, peer: enclosingPeer, member: member)
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var items: [ContextMenuItem] = []
let action = self.action
if actions.contains(.promote) && enclosingPeer is TelegramChannel {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.GroupInfo_ActionPromote, icon: { _ in
return nil
}, action: { c, _ in
c?.dismiss(completion: {
action(member, .promote)
})
})))
}
if actions.contains(.restrict) {
if enclosingPeer is TelegramChannel {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.GroupInfo_ActionRestrict, icon: { _ in
return nil
}, action: { c, _ in
c?.dismiss(completion: {
action(member, .restrict)
})
})))
}
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Delete, textColor: .destructive, icon: { _ in
return nil
}, action: { c, _ in
c?.dismiss(completion: {
action(member, .remove)
})
})))
}
if items.isEmpty {
gesture?.cancel()
return
}
let dismissPromise = ValuePromise<Bool>(false)
let source = PeerInfoMemberExtractedContentSource(sourceNode: node, keepInPlace: false, blurBackground: true, centerVertically: false, shouldBeDismissed: dismissPromise.get())
let contextController = ContextController(presentationData: presentationData, source: .extracted(source), items: .single(ContextController.Items(content: .list(items))), gesture: gesture)
self.parentController?.presentInGlobalOverlay(contextController)
})
self.enclosingPeer = enclosingPeer
self.currentEntries = entries
self.enqueuedTransactions.append(transaction)
self.dequeueTransaction()
}
private func dequeueTransaction() {
guard let _ = self.currentParams, let transaction = self.enqueuedTransactions.first else {
return
}
self.enqueuedTransactions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if transaction.animated {
options.insert(.AnimateInsertion)
} else {
options.insert(.Synchronous)
}
self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
if !strongSelf.didSetReady {
strongSelf.didSetReady = true
strongSelf.ready.set(.single(true))
}
})
}
func findLoadedMessage(id: MessageId) -> Message? {
return nil
}
func updateHiddenMedia() {
}
func transferVelocity(_ velocity: CGFloat) {
if velocity > 0.0 {
self.listNode.transferVelocity(velocity)
}
}
func cancelPreviewGestures() {
}
func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func addToTransitionSurface(view: UIView) {
}
func updateSelectedMessages(animated: Bool) {
}
}
final class PeerInfoMemberExtractedContentSource: ContextExtractedContentSource {
var keepInPlace: Bool
let ignoreContentTouches: Bool = false
let blurBackground: Bool
private let sourceNode: ContextExtractedContentContainingNode
var centerVertically: Bool
var shouldBeDismissed: Signal<Bool, NoError>
init(sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool, blurBackground: Bool, centerVertically: Bool, shouldBeDismissed: Signal<Bool, NoError>) {
self.sourceNode = sourceNode
self.keepInPlace = keepInPlace
self.blurBackground = blurBackground
self.centerVertically = centerVertically
self.shouldBeDismissed = shouldBeDismissed
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,483 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import TelegramCore
import SwiftSignalKit
import Postbox
import TelegramPresentationData
import AccountContext
import ContextUI
import PhotoResources
import TelegramUIPreferences
import ItemListPeerItem
import ItemListPeerActionItem
import MergeLists
import ItemListUI
import PeerInfoVisualMediaPaneNode
import ChatControllerInteraction
import MultilineTextComponent
import Markdown
import SolidRoundedButtonNode
import PeerInfoPaneNode
private struct RecommendedPeersListTransaction {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let animated: Bool
}
private enum RecommendedPeersListEntryStableId: Hashable {
case addMember
case peer(PeerId)
}
private enum RecommendedPeersListEntry: Comparable, Identifiable {
case peer(theme: PresentationTheme, index: Int, peer: EnginePeer, subscribers: Int32)
var stableId: RecommendedPeersListEntryStableId {
switch self {
case let .peer(_, _, peer, _):
return .peer(peer.id)
}
}
static func ==(lhs: RecommendedPeersListEntry, rhs: RecommendedPeersListEntry) -> Bool {
switch lhs {
case let .peer(lhsTheme, lhsIndex, lhsPeer, lhsSubscribers):
if case let .peer(rhsTheme, rhsIndex, rhsPeer, rhsSubscribers) = rhs, lhsTheme === rhsTheme, lhsIndex == rhsIndex, lhsPeer == rhsPeer, lhsSubscribers == rhsSubscribers {
return true
} else {
return false
}
}
}
static func <(lhs: RecommendedPeersListEntry, rhs: RecommendedPeersListEntry) -> Bool {
switch lhs {
case let .peer(_, lhsIndex, _, _):
switch rhs {
case let .peer(_, rhsIndex, _, _):
return lhsIndex < rhsIndex
}
}
}
func item(context: AccountContext, presentationData: PresentationData, action: @escaping (EnginePeer) -> Void, openPeerContextAction: @escaping (Peer, ASDisplayNode, ContextGesture?) -> Void) -> ListViewItem {
switch self {
case let .peer(_, _, peer, subscribers):
let text: ItemListPeerItemText
if subscribers > 0 {
if peer.id.namespace == Namespaces.Peer.CloudChannel {
text = .text(presentationData.strings.SharedMedia_SimilarChannel_Subscribers(subscribers), .secondary)
} else {
text = .text(presentationData.strings.SharedMedia_SimilarBot_Users(subscribers), .secondary)
}
} else if let addressName = peer.addressName {
text = .text("@\(addressName)", .secondary)
} else {
text = .none
}
return ItemListPeerItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peer, presence: nil, text: text, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, selectable: true, sectionId: 0, action: {
action(peer)
}, setPeerIdWithRevealedOptions: { _, _ in
}, removePeer: { _ in
}, contextAction: { node, gesture in
openPeerContextAction(peer._asPeer(), node, gesture)
}, hasTopStripe: false, noInsets: true, noCorners: true, style: .plain, disableInteractiveTransitionIfNecessary: true)
}
}
}
private func preparedTransition(from fromEntries: [RecommendedPeersListEntry], to toEntries: [RecommendedPeersListEntry], context: AccountContext, presentationData: PresentationData, action: @escaping (EnginePeer) -> Void, openPeerContextAction: @escaping (Peer, ASDisplayNode, ContextGesture?) -> Void) -> RecommendedPeersListTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, action: action, openPeerContextAction: openPeerContextAction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, action: action, openPeerContextAction: openPeerContextAction), directionHint: nil) }
return RecommendedPeersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count)
}
private protocol RecommendedPeers {
}
extension RecommendedChannels: RecommendedPeers {
}
extension RecommendedBots: RecommendedPeers {
}
final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode {
private let context: AccountContext
private let chatControllerInteraction: ChatControllerInteraction
private let openPeerContextAction: (Bool, Peer, ASDisplayNode, ContextGesture?) -> Void
weak var parentController: ViewController?
private let listNode: ListView
private var currentEntries: [RecommendedPeersListEntry] = []
private var enqueuedTransactions: [RecommendedPeersListTransaction] = []
private var currentState: (RecommendedPeers?, Bool)?
private var unlockBackground: UIImageView?
private var unlockText: ComponentView<Empty>?
private var unlockButton: SolidRoundedButtonNode?
private var currentParams: (size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, isScrollingLockedAtTop: Bool, presentationData: PresentationData)?
private var theme: PresentationTheme?
private let presentationDataPromise = Promise<PresentationData>()
private let ready = Promise<Bool>()
private var didSetReady: Bool = false
var isReady: Signal<Bool, NoError> {
return self.ready.get()
}
private let statusPromise = Promise<PeerInfoStatusData?>(nil)
var status: Signal<PeerInfoStatusData?, NoError> {
self.statusPromise.get()
}
var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)?
var tabBarOffset: CGFloat {
return 0.0
}
private var disposable: Disposable?
init(context: AccountContext, peerId: PeerId, chatControllerInteraction: ChatControllerInteraction, openPeerContextAction: @escaping (Bool, Peer, ASDisplayNode, ContextGesture?) -> Void) {
self.context = context
self.chatControllerInteraction = chatControllerInteraction
self.openPeerContextAction = openPeerContextAction
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.listNode = ListView()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
super.init()
self.listNode.preloadPages = true
self.addSubnode(self.listNode)
let signal: Signal<RecommendedPeers?, NoError>
if peerId.namespace == Namespaces.Peer.CloudUser {
signal = context.engine.peers.recommendedBots(peerId: peerId)
|> map {
$0 as RecommendedPeers?
}
} else {
signal = context.engine.peers.recommendedChannels(peerId: peerId)
|> map {
$0 as RecommendedPeers?
}
}
self.disposable = (combineLatest(queue: .mainQueue(),
self.presentationDataPromise.get(),
signal,
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
return peer?.isPremium ?? false
}
)
|> deliverOnMainQueue).startStrict(next: { [weak self] presentationData, recommendedPeers, isPremium in
guard let self else {
return
}
self.currentState = (recommendedPeers, isPremium)
self.updateState(recommendedPeers: recommendedPeers, isPremium: isPremium, presentationData: presentationData)
})
self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in
if let self {
self.layoutUnlockPanel(transition: .animated(duration: 0.4, curve: .spring))
}
}
}
deinit {
self.disposable?.dispose()
}
func ensureMessageIsVisible(id: MessageId) {
}
func scrollToTop() -> Bool {
if !self.listNode.scrollToOffsetFromTop(0.0, animated: true) {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
return true
} else {
return false
}
}
func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) {
let isFirstLayout = self.currentParams == nil
self.currentParams = (size, sideInset, bottomInset, isScrollingLockedAtTop, presentationData)
self.presentationDataPromise.set(.single(presentationData))
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size))
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
var scrollToItem: ListViewScrollToItem?
if isScrollingLockedAtTop {
switch self.listNode.visibleContentOffset() {
case let .known(value) where value <= CGFloat.ulpOfOne:
break
default:
scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Spring(duration: duration), directionHint: .Up)
}
}
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: scrollToItem, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.listNode.scrollEnabled = !isScrollingLockedAtTop
if isFirstLayout, let (recommendedPeers, isPremium) = self.currentState {
self.updateState(recommendedPeers: recommendedPeers, isPremium: isPremium, presentationData: presentationData)
}
}
@objc private func unlockPressed() {
let controller = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .ads, forceDark: false, dismissed: nil)
self.chatControllerInteraction.navigationController()?.pushViewController(controller)
}
private func updateState(recommendedPeers: RecommendedPeers?, isPremium: Bool, presentationData: PresentationData) {
if let recommendedChannels = recommendedPeers as? RecommendedChannels {
self.updateState(recommendedChannels: recommendedChannels, isPremium: isPremium, presentationData: presentationData)
} else if let recommendedBots = recommendedPeers as? RecommendedBots {
self.updateState(recommendedBots: recommendedBots, isPremium: isPremium, presentationData: presentationData)
}
}
private func updateState(recommendedChannels: RecommendedChannels?, isPremium: Bool, presentationData: PresentationData) {
var entries: [RecommendedPeersListEntry] = []
if let channels = recommendedChannels?.channels {
for channel in channels {
entries.append(.peer(theme: presentationData.theme, index: entries.count, peer: channel.peer, subscribers: channel.subscribers))
}
}
let transaction = preparedTransition(from: self.currentEntries, to: entries, context: self.context, presentationData: presentationData, action: { [weak self] peer in
self?.chatControllerInteraction.openPeer(peer, .default, nil, .default)
}, openPeerContextAction: { [weak self] peer, node, gesture in
self?.openPeerContextAction(true, peer, node, gesture)
})
self.currentEntries = entries
self.enqueuedTransactions.append(transaction)
self.dequeueTransaction()
if let recommendedChannels {
self.statusPromise.set(.single(
PeerInfoStatusData(text: presentationData.strings.SharedMedia_SimilarChannelCount(recommendedChannels.count), isActivity: true, key: .similarChannels)
))
}
}
private func updateState(recommendedBots: RecommendedBots?, isPremium: Bool, presentationData: PresentationData) {
var entries: [RecommendedPeersListEntry] = []
if let bots = recommendedBots?.bots {
for bot in bots {
var subscriberCount: Int32 = 0
if case let .user(user) = bot {
subscriberCount = user.subscriberCount ?? 0
}
entries.append(.peer(theme: presentationData.theme, index: entries.count, peer: bot, subscribers: subscriberCount))
}
}
let transaction = preparedTransition(from: self.currentEntries, to: entries, context: self.context, presentationData: presentationData, action: { [weak self] peer in
self?.chatControllerInteraction.openPeer(peer, .info(nil), nil, .default)
}, openPeerContextAction: { [weak self] peer, node, gesture in
self?.openPeerContextAction(true, peer, node, gesture)
})
self.currentEntries = entries
self.enqueuedTransactions.append(transaction)
self.dequeueTransaction()
if let recommendedBots {
self.statusPromise.set(.single(
PeerInfoStatusData(text: presentationData.strings.SharedMedia_SimilarBotCount(recommendedBots.count), isActivity: true, key: .similarBots)
))
}
}
private func layoutUnlockPanel(transition: ContainedViewLayoutTransition) {
guard let (_, isPremium) = self.currentState, let currentParams = self.currentParams else {
return
}
if !isPremium {
var transition = transition
let size = currentParams.size
let sideInset = currentParams.sideInset
let bottomInset = currentParams.bottomInset
let presentationData = currentParams.presentationData
let themeUpdated = self.theme !== presentationData.theme
self.theme = presentationData.theme
let unlockText: ComponentView<Empty>
let unlockBackground: UIImageView
let unlockButton: SolidRoundedButtonNode
if let current = self.unlockText {
unlockText = current
} else {
unlockText = ComponentView<Empty>()
self.unlockText = unlockText
transition = .immediate
}
if let current = self.unlockBackground {
unlockBackground = current
} else {
unlockBackground = UIImageView()
unlockBackground.contentMode = .scaleToFill
self.view.addSubview(unlockBackground)
self.unlockBackground = unlockBackground
}
var isBots = false
if let (state, _) = self.currentState, state is RecommendedBots {
isBots = true
}
if let current = self.unlockButton {
unlockButton = current
} else {
unlockButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: presentationData.theme), height: 50.0, cornerRadius: 10.0)
self.view.addSubview(unlockButton.view)
self.unlockButton = unlockButton
unlockButton.animationLoopTime = 2.5
unlockButton.animation = "premium_unlock"
unlockButton.iconPosition = .right
unlockButton.title = isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMore : presentationData.strings.Channel_SimilarChannels_ShowMore
unlockButton.pressed = { [weak self] in
self?.unlockPressed()
}
}
if themeUpdated {
let topColor = presentationData.theme.list.plainBackgroundColor.withAlphaComponent(0.0)
let bottomColor = presentationData.theme.list.plainBackgroundColor
unlockBackground.image = generateGradientImage(size: CGSize(width: 1.0, height: 170.0), colors: [topColor, bottomColor, bottomColor], locations: [0.0, 0.3, 1.0])
unlockButton.updateTheme(SolidRoundedButtonTheme(theme: presentationData.theme))
}
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = presentationData.theme.list.itemSecondaryTextColor
let linkColor = presentationData.theme.list.itemAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in
return nil
})
var scrollOffset: CGFloat = 0.0
if case let .known(offset) = self.listNode.visibleBottomContentOffset() {
scrollOffset = min(0.0, offset + bottomInset + 80.0)
}
let unlockSize = unlockText.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .markdown(text: isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMoreInfo : presentationData.strings.Channel_SimilarChannels_ShowMoreInfo, attributes: markdownAttributes),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
)
),
environment: {},
containerSize: CGSize(width: size.width - 32.0, height: 200.0)
)
if let view = unlockText.view {
if view.superview == nil {
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.unlockPressed)))
self.view.addSubview(view)
}
transition.updateFrame(view: view, frame: CGRect(origin: CGPoint(x: floor((size.width - unlockSize.width) / 2.0), y: size.height - bottomInset - unlockSize.height - 13.0 + scrollOffset), size: unlockSize))
}
transition.updateFrame(view: unlockBackground, frame: CGRect(x: 0.0, y: size.height - bottomInset - 170.0 + scrollOffset, width: size.width, height: bottomInset + 170.0))
let buttonSideInset = sideInset + 16.0
let buttonSize = CGSize(width: size.width - buttonSideInset * 2.0, height: 50.0)
transition.updateFrame(node: unlockButton, frame: CGRect(origin: CGPoint(x: buttonSideInset, y: size.height - bottomInset - unlockSize.height - buttonSize.height - 26.0 + scrollOffset), size: buttonSize))
let _ = unlockButton.updateLayout(width: buttonSize.width, transition: .immediate)
} else {
self.unlockBackground?.removeFromSuperview()
self.unlockBackground = nil
self.unlockButton?.view.removeFromSuperview()
self.unlockButton = nil
self.unlockText?.view?.removeFromSuperview()
self.unlockText = nil
}
}
private func dequeueTransaction() {
guard let _ = self.currentParams, let transaction = self.enqueuedTransactions.first else {
return
}
self.enqueuedTransactions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if transaction.animated {
options.insert(.AnimateInsertion)
} else {
options.insert(.Synchronous)
}
self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
if !strongSelf.didSetReady {
strongSelf.didSetReady = true
strongSelf.ready.set(.single(true))
}
})
}
func findLoadedMessage(id: MessageId) -> Message? {
return nil
}
func updateHiddenMedia() {
}
func transferVelocity(_ velocity: CGFloat) {
if velocity > 0.0 {
self.listNode.transferVelocity(velocity)
}
}
func cancelPreviewGestures() {
}
func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func addToTransitionSurface(view: UIView) {
}
func updateSelectedMessages(animated: Bool) {
}
}
@@ -0,0 +1,179 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import PeerInfoAvatarListNode
import SwiftSignalKit
import Postbox
import TelegramCore
import ContextUI
import AccountContext
import Display
final class PeerInfoAvatarListNode: ASDisplayNode {
private let isSettings: Bool
let containerNode: ASDisplayNode
let pinchSourceNode: PinchSourceContainerNode
let bottomCoverNode: ASDisplayNode
let maskNode: DynamicIslandMaskNode
let topCoverNode: DynamicIslandBlurNode
let avatarContainerNode: PeerInfoAvatarTransformContainerNode
let listContainerTransformNode: ASDisplayNode
let listContainerNode: PeerInfoAvatarListContainerNode
let isReady = Promise<Bool>()
var arguments: (Peer?, Int64?, EngineMessageHistoryThread.Info?, PresentationTheme, CGFloat, Bool)?
var item: PeerInfoAvatarListItem?
var itemsUpdated: (([PeerInfoAvatarListItem]) -> Void)?
var animateOverlaysFadeIn: (() -> Void)?
var openStories: (() -> Void)?
init(context: AccountContext, readyWhenGalleryLoads: Bool, isSettings: Bool) {
self.isSettings = isSettings
self.containerNode = ASDisplayNode()
self.bottomCoverNode = ASDisplayNode()
self.maskNode = DynamicIslandMaskNode()
self.pinchSourceNode = PinchSourceContainerNode()
self.avatarContainerNode = PeerInfoAvatarTransformContainerNode(context: context)
self.listContainerTransformNode = ASDisplayNode()
self.listContainerNode = PeerInfoAvatarListContainerNode(context: context, isSettings: isSettings)
self.listContainerNode.clipsToBounds = true
self.listContainerNode.isHidden = true
self.topCoverNode = DynamicIslandBlurNode()
super.init()
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.bottomCoverNode)
self.containerNode.addSubnode(self.pinchSourceNode)
self.pinchSourceNode.contentNode.addSubnode(self.avatarContainerNode)
self.listContainerTransformNode.addSubnode(self.listContainerNode)
self.pinchSourceNode.contentNode.addSubnode(self.listContainerTransformNode)
self.containerNode.addSubnode(self.topCoverNode)
let avatarReady = (self.avatarContainerNode.avatarNode.ready
|> mapToSignal { _ -> Signal<Bool, NoError> in
return .complete()
}
|> then(.single(true)))
let galleryReady = self.listContainerNode.isReady.get()
|> filter { value in
return value
}
|> take(1)
let combinedSignal: Signal<Bool, NoError>
if readyWhenGalleryLoads {
combinedSignal = combineLatest(queue: .mainQueue(),
avatarReady,
galleryReady
)
|> map { lhs, rhs in
return lhs && rhs
}
} else {
combinedSignal = avatarReady
}
self.isReady.set(combinedSignal
|> filter { value in
return value
}
|> take(1))
self.listContainerNode.itemsUpdated = { [weak self] items in
if let strongSelf = self {
strongSelf.item = items.first
strongSelf.itemsUpdated?(items)
if let (peer, threadId, threadInfo, theme, avatarSize, isExpanded) = strongSelf.arguments {
strongSelf.avatarContainerNode.update(peer: peer, threadId: threadId, threadInfo: threadInfo, item: strongSelf.item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded, isSettings: strongSelf.isSettings)
}
}
}
self.pinchSourceNode.activate = { [weak self] sourceNode in
guard let strongSelf = self, let (_, _, _, _, _, isExpanded) = strongSelf.arguments, isExpanded else {
return
}
let pinchController = PinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: {
return UIScreen.main.bounds
})
context.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController)
strongSelf.listContainerNode.bottomShadowNode.alpha = 0.0
strongSelf.listContainerNode.contentNode.updateIsInPinchMode(true)
}
self.pinchSourceNode.animatedOut = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.animateOverlaysFadeIn?()
strongSelf.listContainerNode.contentNode.updateIsInPinchMode(false)
}
self.listContainerNode.openStories = { [weak self] in
guard let self else {
return
}
self.openStories?()
}
}
func update(size: CGSize, avatarSize: CGFloat, isExpanded: Bool, peer: Peer?, isForum: Bool, threadId: Int64?, threadInfo: EngineMessageHistoryThread.Info?, theme: PresentationTheme, transition: ContainedViewLayoutTransition) {
self.arguments = (peer, threadId, threadInfo, theme, avatarSize, isExpanded)
self.maskNode.isForum = isForum
self.pinchSourceNode.update(size: size, transition: transition)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
self.pinchSourceNode.frame = CGRect(origin: CGPoint(), size: size)
self.avatarContainerNode.update(peer: peer, threadId: threadId, threadInfo: threadInfo, item: self.item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded, isSettings: self.isSettings)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.listContainerNode.isHidden {
if let result = self.listContainerNode.view.hitTest(self.view.convert(point, to: self.listContainerNode.view), with: event) {
return result
}
} else {
if let result = self.avatarContainerNode.avatarNode.view.hitTest(self.view.convert(point, to: self.avatarContainerNode.avatarNode.view), with: event) {
return result
} else if let result = self.avatarContainerNode.iconView?.view?.hitTest(self.view.convert(point, to: self.avatarContainerNode.iconView?.view), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
func animateAvatarCollapse(transition: ContainedViewLayoutTransition) {
if let currentItemNode = self.listContainerNode.currentItemNode, case .animated = transition {
if let _ = self.avatarContainerNode.videoNode {
} else if let _ = self.avatarContainerNode.markupNode {
} else if let unroundedImage = self.avatarContainerNode.avatarNode.unroundedImage {
let avatarCopyView = UIImageView()
avatarCopyView.image = unroundedImage
avatarCopyView.frame = self.avatarContainerNode.avatarNode.frame
avatarCopyView.center = currentItemNode.imageNode.position
currentItemNode.view.addSubview(avatarCopyView)
let scale = currentItemNode.imageNode.bounds.height / avatarCopyView.bounds.height
avatarCopyView.layer.transform = CATransform3DMakeScale(scale, scale, scale)
avatarCopyView.alpha = 0.0
transition.updateAlpha(layer: avatarCopyView.layer, alpha: 1.0, completion: { [weak avatarCopyView] _ in
Queue.mainQueue().after(0.1, {
avatarCopyView?.removeFromSuperview()
})
})
}
}
}
}
@@ -0,0 +1,519 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import AccountContext
import AvatarNode
import UniversalMediaPlayer
import Display
import ComponentFlow
import UniversalMediaPlayer
import AvatarVideoNode
import SwiftSignalKit
import TelegramUniversalVideoContent
import PeerInfoAvatarListNode
import Postbox
import TelegramCore
import EmojiStatusComponent
import GalleryUI
import HierarchyTrackingLayer
final class PeerInfoAvatarTransformContainerNode: ASDisplayNode {
let context: AccountContext
let containerNode: ContextControllerSourceNode
let avatarNode: AvatarNode
private(set) var avatarStoryView: ComponentView<Empty>?
var videoNode: UniversalVideoNode?
var markupNode: AvatarVideoNode?
var iconView: ComponentView<Empty>?
private var videoContent: NativeVideoContent?
private var videoStartTimestamp: Double?
private let hierarchyTrackingLayer = HierarchyTrackingLayer()
var isExpanded: Bool = false
var canAttachVideo: Bool = true {
didSet {
if oldValue != self.canAttachVideo {
self.videoNode?.canAttachContent = !self.isExpanded && self.canAttachVideo
}
}
}
var tapped: (() -> Void)?
var emojiTapped: (() -> Void)?
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
private var isFirstAvatarLoading = true
var item: PeerInfoAvatarListItem?
private let playbackStartDisposable = MetaDisposable()
var storyData: (totalCount: Int, unseenCount: Int, hasUnseenCloseFriends: Bool, hasLiveItems: Bool)?
var storyProgress: Float?
init(context: AccountContext) {
self.context = context
self.containerNode = ContextControllerSourceNode()
let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0))
self.avatarNode = AvatarNode(font: avatarFont)
super.init()
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.avatarNode)
self.containerNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0))
self.avatarNode.frame = self.containerNode.bounds
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.avatarNode.view.addGestureRecognizer(tapGestureRecognizer)
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
tapGestureRecognizer.isEnabled = false
tapGestureRecognizer.isEnabled = true
strongSelf.contextAction?(strongSelf.containerNode, gesture)
}
self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in
guard let self else {
return
}
if value {
self.updateFromParams()
} else {
self.videoNode?.removeFromSupernode()
self.videoNode = nil
self.videoContent = nil
}
}
self.layer.addSublayer(self.hierarchyTrackingLayer)
}
deinit {
self.playbackStartDisposable.dispose()
}
func updateStoryView(transition: ContainedViewLayoutTransition, theme: PresentationTheme, peer: Peer?) {
var colors = AvatarNode.Colors(theme: theme)
let regularNavigationContentsSecondaryColor: UIColor
if case let .starGift(_, _, _, _, _, innerColorValue, outerColorValue, _, _) = peer?.emojiStatus?.content {
let innerColor = UIColor(rgb: UInt32(bitPattern: innerColorValue))
let outerColor = UIColor(rgb: UInt32(bitPattern: outerColorValue))
regularNavigationContentsSecondaryColor = UIColor(white: 1.0, alpha: 0.6).blitOver(innerColor.withMultiplied(hue: 1.0, saturation: 2.2, brightness: 1.5), alpha: 1.0)
let baseBackgroundColor = UIColor(white: 1.0, alpha: 0.75)
let topColor = baseBackgroundColor.blendOver(background: innerColor.mixedWith(outerColor, alpha: 0.1)).withMultiplied(hue: 1.0, saturation: 1.2, brightness: 1.5)
let bottomColor = baseBackgroundColor.blendOver(background: outerColor).withMultiplied(hue: 1.0, saturation: 1.2, brightness: 1.5)
colors.unseenColors = [topColor, bottomColor]
colors.unseenCloseFriendsColors = colors.unseenColors
colors.seenColors = colors.unseenColors
} else if let profileColor = peer?.profileColor {
let backgroundColors = self.context.peerNameColors.getProfile(profileColor, dark: theme.overallDarkAppearance)
regularNavigationContentsSecondaryColor = UIColor(white: 1.0, alpha: 0.6).blitOver(backgroundColors.main.withMultiplied(hue: 1.0, saturation: 2.2, brightness: 1.5), alpha: 1.0)
let storyColors = self.context.peerNameColors.getProfile(profileColor, dark: theme.overallDarkAppearance, subject: .stories)
var unseenColors: [UIColor] = [storyColors.main]
if let secondary = storyColors.secondary {
unseenColors.insert(secondary, at: 0)
}
colors.unseenColors = unseenColors
colors.unseenCloseFriendsColors = colors.unseenColors
colors.seenColors = colors.unseenColors
} else {
regularNavigationContentsSecondaryColor = theme.list.controlSecondaryColor
}
colors.seenColors = [
regularNavigationContentsSecondaryColor,
regularNavigationContentsSecondaryColor
]
var storyStats: AvatarNode.StoryStats?
if let storyData = self.storyData {
storyStats = AvatarNode.StoryStats(
totalCount: storyData.totalCount,
unseenCount: storyData.unseenCount,
hasUnseenCloseFriendsItems: storyData.hasUnseenCloseFriends,
hasLiveItems: storyData.hasLiveItems,
progress: self.storyProgress
)
} else if let storyProgress = self.storyProgress {
storyStats = AvatarNode.StoryStats(
totalCount: 1,
unseenCount: 1,
hasUnseenCloseFriendsItems: false,
hasLiveItems: false,
progress: storyProgress
)
}
var isForum = false
if let peer, let channel = peer as? TelegramChannel, channel.isForumOrMonoForum {
isForum = true
}
self.avatarNode.setStoryStats(storyStats: storyStats, presentationParams: AvatarNode.StoryPresentationParams(
colors: colors,
lineWidth: 3.0,
inactiveLineWidth: 1.5,
forceRoundedRect: isForum
), transition: ComponentTransition(transition))
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapped?()
}
}
@objc private func emojiTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.emojiTapped?()
}
}
func updateTransitionFraction(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) {
if let videoNode = self.videoNode {
if case .immediate = transition, fraction == 1.0 {
return
}
if fraction > 0.0 {
videoNode.pause()
} else {
videoNode.play()
}
transition.updateAlpha(node: videoNode, alpha: 1.0 - fraction)
}
if let markupNode = self.markupNode {
if case .immediate = transition, fraction == 1.0 {
return
}
if fraction > 0.0 {
markupNode.updateVisibility(false)
} else {
markupNode.updateVisibility(true)
}
transition.updateAlpha(node: markupNode, alpha: 1.0 - fraction)
}
}
private struct Params {
let peer: Peer?
let threadId: Int64?
let threadInfo: EngineMessageHistoryThread.Info?
let item: PeerInfoAvatarListItem?
let theme: PresentationTheme
let avatarSize: CGFloat
let isExpanded: Bool
let isSettings: Bool
init(peer: Peer?, threadId: Int64?, threadInfo: EngineMessageHistoryThread.Info?, item: PeerInfoAvatarListItem?, theme: PresentationTheme, avatarSize: CGFloat, isExpanded: Bool, isSettings: Bool) {
self.peer = peer
self.threadId = threadId
self.threadInfo = threadInfo
self.item = item
self.theme = theme
self.avatarSize = avatarSize
self.isExpanded = isExpanded
self.isSettings = isSettings
}
}
var removedPhotoResourceIds = Set<String>()
private var params: Params?
private func updateFromParams() {
guard let params = self.params else {
return
}
self.update(
peer: params.peer,
threadId: params.threadId,
threadInfo: params.threadInfo,
item: params.item,
theme: params.theme,
avatarSize: params.avatarSize,
isExpanded: params.isExpanded,
isSettings: params.isSettings
)
}
func update(peer: Peer?, threadId: Int64?, threadInfo: EngineMessageHistoryThread.Info?, item: PeerInfoAvatarListItem?, theme: PresentationTheme, avatarSize: CGFloat, isExpanded: Bool, isSettings: Bool) {
self.params = Params(peer: peer, threadId: threadId, threadInfo: threadInfo, item: item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded, isSettings: isSettings)
if let peer = peer {
let previousItem = self.item
var item = item
self.item = item
var overrideImage: AvatarNodeImageOverride?
if peer.isDeleted {
overrideImage = .deletedIcon
} else if let previousItem = previousItem, item == nil {
if case let .image(_, representations, _, _, _, _) = previousItem, let rep = representations.last {
self.removedPhotoResourceIds.insert(rep.representation.resource.id.stringRepresentation)
}
overrideImage = AvatarNodeImageOverride.none
item = nil
} else if let rep = peer.profileImageRepresentations.last, self.removedPhotoResourceIds.contains(rep.resource.id.stringRepresentation) {
overrideImage = AvatarNodeImageOverride.none
item = nil
}
if let _ = overrideImage {
self.containerNode.isGestureEnabled = false
} else if peer.profileImageRepresentations.isEmpty {
self.containerNode.isGestureEnabled = false
} else {
self.containerNode.isGestureEnabled = false
}
self.avatarNode.imageNode.animateFirstTransition = !isSettings
self.avatarNode.setPeer(context: self.context, theme: theme, peer: EnginePeer(peer), overrideImage: overrideImage, clipStyle: .none, synchronousLoad: self.isFirstAvatarLoading, displayDimensions: CGSize(width: avatarSize, height: avatarSize), storeUnrounded: true)
if let threadInfo = threadInfo {
self.avatarNode.isHidden = true
let iconView: ComponentView<Empty>
if let current = self.iconView {
iconView = current
} else {
iconView = ComponentView()
self.iconView = iconView
}
let content: EmojiStatusComponent.Content
if threadId == 1 {
content = .image(image: PresentationResourcesChat.chatGeneralThreadIcon(theme), tintColor: nil)
} else if let iconFileId = threadInfo.icon {
content = .animation(content: .customEmoji(fileId: iconFileId), size: CGSize(width: avatarSize, height: avatarSize), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .forever)
} else {
content = .topic(title: String(threadInfo.title.prefix(1)), color: threadInfo.iconColor, size: CGSize(width: avatarSize, height: avatarSize))
}
let _ = iconView.update(
transition: .immediate,
component: AnyComponent(EmojiStatusComponent(
context: self.context,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
content: content,
isVisibleForAnimations: true,
action: nil
)),
environment: {},
containerSize: CGSize(width: avatarSize, height: avatarSize)
)
if let iconComponentView = iconView.view {
iconComponentView.isUserInteractionEnabled = true
if iconComponentView.superview == nil {
iconComponentView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.emojiTapGesture(_:))))
self.avatarNode.view.superview?.addSubview(iconComponentView)
}
iconComponentView.frame = CGRect(origin: CGPoint(), size: CGSize(width: avatarSize, height: avatarSize))
}
}
var isForum = false
let avatarCornerRadius: CGFloat
if let channel = peer as? TelegramChannel, channel.isForumOrMonoForum {
avatarCornerRadius = floor(avatarSize * 0.25)
isForum = true
} else {
avatarCornerRadius = avatarSize / 2.0
}
if self.avatarNode.layer.cornerRadius != 0.0 {
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut).updateCornerRadius(layer: self.avatarNode.contentNode.layer, cornerRadius: avatarCornerRadius)
} else {
self.avatarNode.contentNode.layer.cornerRadius = avatarCornerRadius
}
self.avatarNode.contentNode.layer.masksToBounds = true
self.isFirstAvatarLoading = false
self.containerNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize))
self.avatarNode.frame = self.containerNode.bounds
self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0))
if let item = item {
let representations: [ImageRepresentationWithReference]
let videoRepresentations: [VideoRepresentationWithReference]
let immediateThumbnailData: Data?
var videoId: Int64
let markup: TelegramMediaImage.EmojiMarkup?
switch item {
case .custom:
representations = []
videoRepresentations = []
immediateThumbnailData = nil
videoId = 0
markup = nil
case let .topImage(topRepresentations, videoRepresentationsValue, immediateThumbnail):
representations = topRepresentations
videoRepresentations = videoRepresentationsValue
immediateThumbnailData = immediateThumbnail
videoId = peer.id.id._internalGetInt64Value()
if let resource = videoRepresentations.first?.representation.resource as? CloudPhotoSizeMediaResource {
videoId = videoId &+ resource.photoId
}
markup = nil
case let .image(reference, imageRepresentations, videoRepresentationsValue, immediateThumbnail, _, markupValue):
representations = imageRepresentations
videoRepresentations = videoRepresentationsValue
immediateThumbnailData = immediateThumbnail
if case let .cloud(imageId, _, _) = reference {
videoId = imageId
} else {
videoId = peer.id.id._internalGetInt64Value()
}
markup = markupValue
}
self.containerNode.isGestureEnabled = !isSettings
if let markup {
if let videoNode = self.videoNode {
self.videoContent = nil
self.videoStartTimestamp = nil
self.videoNode = nil
videoNode.removeFromSupernode()
}
let markupNode: AvatarVideoNode
if let current = self.markupNode {
markupNode = current
} else {
markupNode = AvatarVideoNode(context: self.context)
self.avatarNode.contentNode.addSubnode(markupNode)
self.markupNode = markupNode
}
markupNode.update(markup: markup, size: CGSize(width: 320.0, height: 320.0))
markupNode.updateVisibility(true)
} else if threadInfo == nil, let video = videoRepresentations.last, let peerReference = PeerReference(peer) {
let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []))
let videoContent = NativeVideoContent(id: .profileVideo(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: peer.isCopyProtectionEnabled, storeAfterDownload: nil)
if videoContent.id != self.videoContent?.id {
self.videoNode?.removeFromSupernode()
if self.hierarchyTrackingLayer.isInHierarchy {
let mediaManager = self.context.sharedContext.mediaManager
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded)
videoNode.isUserInteractionEnabled = false
videoNode.isHidden = true
if let startTimestamp = video.representation.startTimestamp {
self.videoStartTimestamp = startTimestamp
self.playbackStartDisposable.set((videoNode.status
|> map { status -> Bool in
if let status = status, case .playing = status.status {
return true
} else {
return false
}
}
|> filter { playing in
return playing
}
|> take(1)
|> deliverOnMainQueue).start(completed: { [weak self] in
if let strongSelf = self {
Queue.mainQueue().after(0.15) {
strongSelf.videoNode?.isHidden = false
}
}
}))
} else {
self.videoStartTimestamp = nil
self.playbackStartDisposable.set(nil)
videoNode.isHidden = false
}
self.videoContent = videoContent
self.videoNode = videoNode
let maskPath: UIBezierPath
if isForum {
maskPath = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size), cornerRadius: avatarCornerRadius)
} else {
maskPath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size))
}
let shape = CAShapeLayer()
shape.path = maskPath.cgPath
videoNode.layer.mask = shape
self.avatarNode.contentNode.addSubnode(videoNode)
}
}
} else {
if let markupNode = self.markupNode {
self.markupNode = nil
markupNode.removeFromSupernode()
}
if let videoNode = self.videoNode {
self.videoStartTimestamp = nil
self.videoContent = nil
self.videoNode = nil
videoNode.removeFromSupernode()
}
}
} else {
if let markupNode = self.markupNode {
self.markupNode = nil
markupNode.removeFromSupernode()
}
if let videoNode = self.videoNode {
self.videoStartTimestamp = nil
self.videoContent = nil
self.videoNode = nil
videoNode.removeFromSupernode()
}
self.containerNode.isGestureEnabled = false
}
if let markupNode = self.markupNode {
markupNode.frame = self.avatarNode.bounds
markupNode.updateLayout(size: self.avatarNode.bounds.size, cornerRadius: avatarCornerRadius, transition: .immediate)
}
if let videoNode = self.videoNode {
if self.canAttachVideo {
videoNode.updateLayout(size: self.avatarNode.frame.size, transition: .immediate)
}
videoNode.frame = self.avatarNode.contentNode.bounds
if isExpanded == videoNode.canAttachContent {
self.isExpanded = isExpanded
let update = {
videoNode.canAttachContent = !self.isExpanded && self.canAttachVideo
if videoNode.canAttachContent {
videoNode.play()
}
}
if isExpanded {
DispatchQueue.main.async {
update()
}
} else {
update()
}
}
}
}
self.updateStoryView(transition: .immediate, theme: theme, peer: peer)
}
}
@@ -0,0 +1,245 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import ConfettiEffect
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import TelegramStringFormatting
final class PeerInfoBirthdayOverlay: ASDisplayNode {
private let context: AccountContext
private var disposable: Disposable?
init(context: AccountContext) {
self.context = context
super.init()
self.isUserInteractionEnabled = false
}
deinit {
self.disposable?.dispose()
}
func setup(size: CGSize, birthday: TelegramBirthday, sourceRect: CGRect?) {
self.setupAnimations(size: size, birthday: birthday, sourceRect: sourceRect)
Queue.mainQueue().after(0.1) {
self.view.addSubview(ConfettiView(frame: CGRect(origin: .zero, size: size)))
}
}
private func setupAnimations(size: CGSize, birthday: TelegramBirthday, sourceRect: CGRect?) {
self.disposable = (combineLatest(
self.context.engine.stickers.loadedStickerPack(reference: .animatedEmojiAnimations, forceActualized: false),
self.context.engine.stickers.loadedStickerPack(reference: .name("FestiveFontEmoji"), forceActualized: false)
)
|> map { animatedEmoji, numbers -> (FileMediaReference?, [FileMediaReference]) in
var effectFile: FileMediaReference?
if case let .result(_, items, _) = animatedEmoji {
let randomKey = ["🎉", "🎈", "🎆"].randomElement()!
outer: for item in items {
let indexKeys = item.getStringRepresentationsOfIndexKeys()
for key in indexKeys {
if key == randomKey {
effectFile = .stickerPack(stickerPack: .animatedEmojiAnimations, media: item.file._parse())
break outer
}
}
}
}
var numberFiles: [FileMediaReference] = []
if let age = ageForBirthday(birthday), case let .result(info, items, _) = numbers {
let ageKeys = ageToKeys(age)
for ageKey in ageKeys {
for item in items {
let indexKeys = item.getStringRepresentationsOfIndexKeys()
for key in indexKeys {
if key == ageKey {
numberFiles.append(.stickerPack(stickerPack: .id(id: info.id.id, accessHash: info.accessHash), media: item.file._parse()))
}
}
}
}
}
return (effectFile, numberFiles)
}
|> deliverOnMainQueue).start(next: { [weak self] effectAndNumberFiles in
guard let self else {
return
}
let (effectFile, numberFiles) = effectAndNumberFiles
if let effectFile {
let _ = freeMediaFileInteractiveFetched(account: self.context.account, userLocation: .peer(self.context.account.peerId), fileReference: effectFile).startStandalone()
self.setupEffectAnimation(size: size, file: effectFile, sourceRect: sourceRect)
}
for file in numberFiles {
let _ = freeMediaFileInteractiveFetched(account: self.context.account, userLocation: .peer(self.context.account.peerId), fileReference: file).startStandalone()
}
self.setupNumberAnimations(size: size, files: numberFiles, sourceRect: sourceRect)
})
}
private func setupEffectAnimation(size: CGSize, file: FileMediaReference, sourceRect: CGRect?) {
guard let dimensions = file.media.dimensions else {
return
}
let minSide = min(size.width, size.height)
let pixelSize = dimensions.cgSize.aspectFitted(CGSize(width: 512.0, height: 512.0))
let animationSize = dimensions.cgSize.aspectFitted(CGSize(width: minSide, height: minSide))
let animationNode = DefaultAnimatedStickerNodeImpl()
let source = AnimatedStickerResourceSource(account: self.context.account, resource: file.media.resource, fitzModifier: nil)
let pathPrefix = self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.media.resource.id)
animationNode.setup(source: source, width: Int(pixelSize.width), height: Int(pixelSize.height), playbackMode: .once, mode: .direct(cachePathPrefix: pathPrefix))
self.addSubnode(animationNode)
let startY = sourceRect?.midY ?? size.height / 2.0
animationNode.updateLayout(size: animationSize)
animationNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
animationNode.frame = CGRect(origin: CGPoint(x: floor((size.width - animationSize.width) / 2.0), y: startY - animationSize.height / 2.0), size: animationSize)
animationNode.visibility = true
}
private func setupNumberAnimations(size: CGSize, files: [FileMediaReference], sourceRect: CGRect?) {
guard !files.isEmpty else {
return
}
let startY = sourceRect?.midY ?? 475.0
var offset: CGFloat = 0.0
var scaleDelay: Double = 0.0
if files.count > 1 {
offset -= 45.0
}
for file in files {
guard let dimensions = file.media.dimensions else {
continue
}
let animationSize = dimensions.cgSize.aspectFitted(CGSize(width: 144.0, height: 144.0))
let pixelSize = dimensions.cgSize.aspectFitted(CGSize(width: 256.0, height: 256.0))
let animationNode = DefaultAnimatedStickerNodeImpl()
let source = AnimatedStickerResourceSource(account: self.context.account, resource: file.media.resource, fitzModifier: nil)
let pathPrefix: String? = self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.media.resource.id)
animationNode.setup(source: source, width: Int(pixelSize.width), height: Int(pixelSize.height), playbackMode: .loop, mode: .direct(cachePathPrefix: pathPrefix))
self.addSubnode(animationNode)
animationNode.updateLayout(size: animationSize)
animationNode.bounds = CGRect(origin: .zero, size: animationSize)
animationNode.transform = CATransform3DMakeScale(0.001, 0.001, 1.0)
animationNode.visibility = true
let path = UIBezierPath()
let startPoint = CGPoint(x: 90.0 + offset * 0.7, y: startY + CGFloat.random(in: -20.0 ..< 20.0))
animationNode.position = startPoint
path.move(to: startPoint)
path.addCurve(to: CGPoint(x: 205.0 + offset, y: -90.0), controlPoint1: CGPoint(x: 213.0 + offset * 0.8, y: 380.0 + CGFloat.random(in: -20.0 ..< 20.0)), controlPoint2: CGPoint(x: 206.0 + offset * 0.8, y: 134.0 + CGFloat.random(in: -20.0 ..< 20.0)))
let riseAnimation = CAKeyframeAnimation(keyPath: "position")
riseAnimation.path = path.cgPath
riseAnimation.duration = 2.2
riseAnimation.calculationMode = .cubic
riseAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn)
riseAnimation.beginTime = CACurrentMediaTime() + 0.5
riseAnimation.isRemovedOnCompletion = false
riseAnimation.fillMode = .forwards
riseAnimation.completion = { [weak self] _ in
self?.removeFromSupernode()
}
animationNode.layer.add(riseAnimation, forKey: "position")
offset += 132.0
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.duration = 2.0
scaleAnimation.values = [0.0, 0.45, 1.0]
scaleAnimation.keyTimes = [0.0, 0.3, 1.0]
scaleAnimation.beginTime = CACurrentMediaTime() + scaleDelay
scaleAnimation.isRemovedOnCompletion = false
scaleAnimation.fillMode = .forwards
animationNode.layer.add(scaleAnimation, forKey: "scale")
scaleDelay += 0.3
}
}
static func preloadBirthdayAnimations(context: AccountContext, birthday: TelegramBirthday) {
let preload = combineLatest(
context.engine.stickers.loadedStickerPack(reference: .animatedEmojiAnimations, forceActualized: false),
context.engine.stickers.loadedStickerPack(reference: .name("FestiveFontEmoji"), forceActualized: false)
)
|> mapToSignal { animatedEmoji, numbers -> Signal<Never, NoError> in
var signals: [Signal<FetchResourceSourceType, FetchResourceError>] = []
if case let .result(_, items, _) = animatedEmoji {
for item in items {
let indexKeys = item.getStringRepresentationsOfIndexKeys()
for key in indexKeys {
if ["🎉", "🎈", "🎆"].contains(key) {
signals.append(freeMediaFileInteractiveFetched(account: context.account, userLocation: .peer(context.account.peerId), fileReference: .stickerPack(stickerPack: .animatedEmojiAnimations, media: item.file._parse())))
}
}
}
}
if let age = ageForBirthday(birthday), case let .result(info, items, _) = numbers {
let ageKeys = ageToKeys(age)
for item in items {
let indexKeys = item.getStringRepresentationsOfIndexKeys()
for key in indexKeys {
if ageKeys.contains(key) {
signals.append(freeMediaFileInteractiveFetched(account: context.account, userLocation: .peer(context.account.peerId), fileReference: .stickerPack(stickerPack: .id(id: info.id.id, accessHash: info.accessHash), media: item.file._parse())))
}
}
}
}
if !signals.isEmpty {
return combineLatest(signals)
|> `catch` { _ in
return .complete()
}
|> ignoreValues
} else {
return .complete()
}
}
let _ = preload.startStandalone()
}
}
private func ageToKeys(_ age: Int) -> [String] {
return "\(age)".map { String($0) }.map {
switch $0 {
case "0":
return "0️⃣"
case "1":
return "1️⃣"
case "2":
return "2️⃣"
case "3":
return "3️⃣"
case "4":
return "4️⃣"
case "5":
return "5️⃣"
case "6":
return "6️⃣"
case "7":
return "7️⃣"
case "8":
return "8️⃣"
case "9":
return "9️⃣"
default:
return $0
}
}
}
@@ -0,0 +1,235 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import AccountContext
import AvatarNode
import UniversalMediaPlayer
import PeerInfoAvatarListNode
import AvatarVideoNode
import TelegramUniversalVideoContent
import SwiftSignalKit
import Postbox
import TelegramCore
import Display
import GalleryUI
final class PeerInfoEditingAvatarNode: ASDisplayNode {
private let context: AccountContext
let avatarNode: AvatarNode
fileprivate var videoNode: UniversalVideoNode?
fileprivate var markupNode: AvatarVideoNode?
private var videoContent: NativeVideoContent?
private var videoStartTimestamp: Double?
var item: PeerInfoAvatarListItem?
var tapped: ((Bool) -> Void)?
var canAttachVideo: Bool = true
init(context: AccountContext) {
self.context = context
let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0))
self.avatarNode = AvatarNode(font: avatarFont)
super.init()
self.addSubnode(self.avatarNode)
self.avatarNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0))
self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapped?(false)
}
}
func reset() {
guard let videoNode = self.videoNode else {
return
}
videoNode.isHidden = true
videoNode.seek(self.videoStartTimestamp ?? 0.0)
Queue.mainQueue().after(0.15) {
videoNode.isHidden = false
}
}
var removedPhotoResourceIds = Set<String>()
func update(peer: Peer?, threadData: MessageHistoryThreadData?, chatLocation: ChatLocation, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: AvatarUploadProgress?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) {
guard let peer = peer else {
return
}
let canEdit = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)
let previousItem = self.item
var item = item
self.item = item
let overrideImage: AvatarNodeImageOverride?
if canEdit, peer.profileImageRepresentations.isEmpty {
overrideImage = .editAvatarIcon(forceNone: true)
} else if let previousItem = previousItem, item == nil {
if case let .image(_, representations, _, _, _, _) = previousItem, let rep = representations.last {
self.removedPhotoResourceIds.insert(rep.representation.resource.id.stringRepresentation)
}
overrideImage = canEdit ? .editAvatarIcon(forceNone: true) : AvatarNodeImageOverride.none
item = nil
} else if let representation = peer.profileImageRepresentations.last, self.removedPhotoResourceIds.contains(representation.resource.id.stringRepresentation) {
overrideImage = canEdit ? .editAvatarIcon(forceNone: true) : AvatarNodeImageOverride.none
item = nil
} else {
overrideImage = item == nil && canEdit ? .editAvatarIcon(forceNone: true) : nil
}
self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0))
self.avatarNode.setPeer(context: self.context, theme: theme, peer: EnginePeer(peer), overrideImage: overrideImage, clipStyle: .none, synchronousLoad: false, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
self.avatarNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize))
var isForum = false
let avatarCornerRadius: CGFloat
if let channel = peer as? TelegramChannel, channel.isForumOrMonoForum {
isForum = true
avatarCornerRadius = floor(avatarSize * 0.25)
} else {
avatarCornerRadius = avatarSize / 2.0
}
if self.avatarNode.layer.cornerRadius != 0.0 {
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut).updateCornerRadius(layer: self.avatarNode.layer, cornerRadius: avatarCornerRadius)
} else {
self.avatarNode.layer.cornerRadius = avatarCornerRadius
}
self.avatarNode.layer.masksToBounds = true
if let item = item {
let representations: [ImageRepresentationWithReference]
let videoRepresentations: [VideoRepresentationWithReference]
let immediateThumbnailData: Data?
var videoId: Int64
let markup: TelegramMediaImage.EmojiMarkup?
switch item {
case .custom:
representations = []
videoRepresentations = []
immediateThumbnailData = nil
videoId = 0
markup = nil
case let .topImage(topRepresentations, videoRepresentationsValue, immediateThumbnail):
representations = topRepresentations
videoRepresentations = videoRepresentationsValue
immediateThumbnailData = immediateThumbnail
videoId = peer.id.id._internalGetInt64Value()
if let resource = videoRepresentations.first?.representation.resource as? CloudPhotoSizeMediaResource {
videoId = videoId &+ resource.photoId
}
markup = nil
case let .image(reference, imageRepresentations, videoRepresentationsValue, immediateThumbnail, _, markupValue):
representations = imageRepresentations
videoRepresentations = videoRepresentationsValue
immediateThumbnailData = immediateThumbnail
if case let .cloud(imageId, _, _) = reference {
videoId = imageId
} else {
videoId = peer.id.id._internalGetInt64Value()
}
markup = markupValue
}
if let markup {
if let videoNode = self.videoNode {
self.videoContent = nil
self.videoStartTimestamp = nil
self.videoNode = nil
videoNode.removeFromSupernode()
}
let markupNode: AvatarVideoNode
if let current = self.markupNode {
markupNode = current
} else {
markupNode = AvatarVideoNode(context: self.context)
self.avatarNode.contentNode.addSubnode(markupNode)
self.markupNode = markupNode
}
markupNode.update(markup: markup, size: CGSize(width: 320.0, height: 320.0))
markupNode.updateVisibility(true)
} else if threadData == nil, let video = videoRepresentations.last, let peerReference = PeerReference(peer) {
if let markupNode = self.markupNode {
self.markupNode = nil
markupNode.removeFromSupernode()
}
let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []))
let videoContent = NativeVideoContent(id: .profileVideo(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: peer.isCopyProtectionEnabled, storeAfterDownload: nil)
if videoContent.id != self.videoContent?.id {
self.videoNode?.removeFromSupernode()
let mediaManager = self.context.sharedContext.mediaManager
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery)
videoNode.isUserInteractionEnabled = false
self.videoStartTimestamp = video.representation.startTimestamp
self.videoContent = videoContent
self.videoNode = videoNode
let maskPath: UIBezierPath
if isForum {
maskPath = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size), cornerRadius: avatarCornerRadius)
} else {
maskPath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: self.avatarNode.frame.size))
}
let shape = CAShapeLayer()
shape.path = maskPath.cgPath
videoNode.layer.mask = shape
self.avatarNode.contentNode.addSubnode(videoNode)
}
} else {
if let markupNode = self.markupNode {
self.markupNode = nil
markupNode.removeFromSupernode()
}
if let videoNode = self.videoNode {
self.videoStartTimestamp = nil
self.videoContent = nil
self.videoNode = nil
DispatchQueue.main.async {
videoNode.removeFromSupernode()
}
}
}
} else if let videoNode = self.videoNode {
self.videoStartTimestamp = nil
self.videoContent = nil
self.videoNode = nil
videoNode.removeFromSupernode()
}
if let markupNode = self.markupNode {
markupNode.frame = self.avatarNode.bounds
markupNode.updateLayout(size: self.avatarNode.bounds.size, cornerRadius: avatarCornerRadius, transition: .immediate)
}
if let videoNode = self.videoNode {
if self.canAttachVideo {
videoNode.updateLayout(size: self.avatarNode.bounds.size, transition: .immediate)
}
videoNode.frame = self.avatarNode.bounds
if isEditing != videoNode.canAttachContent {
videoNode.canAttachContent = isEditing && self.canAttachVideo
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.avatarNode.frame.contains(point) {
return self.avatarNode.view
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,149 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import AccountContext
import Display
import RadialStatusNode
import Postbox
import TelegramCore
import PeerInfoAvatarListNode
import AvatarNode
import SwiftSignalKit
final class PeerInfoEditingAvatarOverlayNode: ASDisplayNode {
private let context: AccountContext
private let imageNode: ImageNode
private let updatingAvatarOverlay: ASImageNode
private let iconNode: ASImageNode
private var statusNode: RadialStatusNode
private var currentRepresentation: TelegramMediaImageRepresentation?
init(context: AccountContext) {
self.context = context
self.imageNode = ImageNode(enableEmpty: true)
self.updatingAvatarOverlay = ASImageNode()
self.updatingAvatarOverlay.displayWithoutProcessing = true
self.updatingAvatarOverlay.displaysAsynchronously = false
self.updatingAvatarOverlay.alpha = 0.0
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.6))
self.statusNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: .white)
self.iconNode.alpha = 0.0
super.init()
self.imageNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0))
self.updatingAvatarOverlay.frame = self.imageNode.frame
let radialStatusSize: CGFloat = 50.0
let imagePosition = self.imageNode.position
self.statusNode.frame = CGRect(origin: CGPoint(x: floor(imagePosition.x - radialStatusSize / 2.0), y: floor(imagePosition.y - radialStatusSize / 2.0)), size: CGSize(width: radialStatusSize, height: radialStatusSize))
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: floor(imagePosition.x - image.size.width / 2.0), y: floor(imagePosition.y - image.size.height / 2.0)), size: image.size)
}
self.addSubnode(self.imageNode)
self.addSubnode(self.updatingAvatarOverlay)
self.addSubnode(self.statusNode)
}
func updateTransitionFraction(_ fraction: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: self, alpha: 1.0 - fraction)
}
func update(peer: Peer?, threadData: MessageHistoryThreadData?, chatLocation: ChatLocation, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: AvatarUploadProgress?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) {
guard let peer = peer else {
return
}
self.imageNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize))
self.updatingAvatarOverlay.frame = self.imageNode.frame
let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear)
let clipStyle: AvatarNodeClipStyle
if let channel = peer as? TelegramChannel, channel.isForumOrMonoForum {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
var isPersonal = false
if let updatingAvatar, case let .image(image) = updatingAvatar, image.isPersonal {
isPersonal = true
}
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)
|| isPersonal
|| self.currentRepresentation != nil && updatingAvatar == nil {
var overlayHidden = true
if let updatingAvatar = updatingAvatar {
overlayHidden = false
var cancelEnabled = true
let progressValue: CGFloat?
if let uploadProgress {
switch uploadProgress {
case let .value(value):
progressValue = max(0.027, value)
case .indefinite:
progressValue = nil
cancelEnabled = false
}
} else {
progressValue = 0.027
}
self.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: progressValue, cancelEnabled: cancelEnabled, animateRotation: true))
if case let .image(representation) = updatingAvatar {
if representation != self.currentRepresentation {
self.currentRepresentation = representation
if let signal = peerAvatarImage(account: context.account, peerReference: nil, authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: avatarSize, height: avatarSize), clipStyle: clipStyle, emptyColor: nil, synchronousLoad: false, provideUnrounded: false) {
self.imageNode.setSignal(signal |> map { $0?.0 })
}
}
}
transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: 1.0)
} else {
let targetOverlayAlpha: CGFloat = 0.0
if self.updatingAvatarOverlay.alpha != targetOverlayAlpha {
let update = {
self.statusNode.transitionToState(.none)
self.currentRepresentation = nil
self.imageNode.setSignal(.single(nil))
transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: overlayHidden ? 0.0 : 1.0)
}
Queue.mainQueue().after(0.3) {
update()
}
}
}
if !overlayHidden && self.updatingAvatarOverlay.image == nil {
switch clipStyle {
case .round:
self.updatingAvatarOverlay.image = generateFilledCircleImage(diameter: avatarSize, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil)
case .roundedRect:
self.updatingAvatarOverlay.image = generateFilledRoundedRectImage(size: CGSize(width: avatarSize, height: avatarSize), cornerRadius: avatarSize * 0.25, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil)
default:
break
}
}
} else {
self.statusNode.transitionToState(.none)
self.currentRepresentation = nil
transition.updateAlpha(node: self.iconNode, alpha: 0.0)
transition.updateAlpha(node: self.updatingAvatarOverlay, alpha: 0.0)
}
}
}
@@ -0,0 +1,88 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import Display
final class PeerInfoHeaderActionButtonNode: HighlightableButtonNode {
let key: PeerInfoHeaderButtonKey
private let action: (PeerInfoHeaderActionButtonNode, ContextGesture?) -> Void
let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
private let backgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private var theme: PresentationTheme?
init(key: PeerInfoHeaderButtonKey, action: @escaping (PeerInfoHeaderActionButtonNode, ContextGesture?) -> Void) {
self.key = key
self.action = action
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.backgroundNode = ASDisplayNode()
self.backgroundNode.cornerRadius = 11.0
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
super.init()
self.accessibilityTraits = .button
self.containerNode.addSubnode(self.referenceNode)
self.referenceNode.addSubnode(self.backgroundNode)
self.addSubnode(self.containerNode)
self.addSubnode(self.textNode)
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.layer.removeAnimation(forKey: "opacity")
strongSelf.alpha = 0.4
} else {
strongSelf.alpha = 1.0
strongSelf.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.containerNode.activated = { [weak self] gesture, _ in
if let strongSelf = self {
strongSelf.action(strongSelf, gesture)
}
}
self.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
@objc private func buttonPressed() {
self.action(self, nil)
}
func update(size: CGSize, text: String, presentationData: PresentationData, transition: ContainedViewLayoutTransition) {
let themeUpdated = self.theme != presentationData.theme
if themeUpdated {
self.theme = presentationData.theme
self.containerNode.isGestureEnabled = false
self.backgroundNode.backgroundColor = presentationData.theme.list.itemAccentColor
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
}
self.textNode.attributedText = NSAttributedString(string: text, font: Font.semibold(16.0), textColor: presentationData.theme.list.itemCheckColors.foregroundColor)
self.accessibilityLabel = text
let titleSize = self.textNode.updateLayout(CGSize(width: 120.0, height: .greatestFiniteMagnitude))
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: size))
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
transition.updateFrameAdditiveToCenter(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize))
self.referenceNode.frame = self.containerNode.bounds
}
}
@@ -0,0 +1,286 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import AnimationUI
import Display
import TelegramPresentationData
import ComponentFlow
import LottieComponent
enum PeerInfoHeaderButtonKey: Hashable {
case message
case discussion
case call
case videoCall
case voiceChat
case mute
case more
case addMember
case search
case leave
case stop
case addContact
}
enum PeerInfoHeaderButtonIcon {
case message
case call
case videoCall
case voiceChat
case mute
case unmute
case more
case addMember
case search
case leave
case stop
}
final class PeerInfoHeaderButtonNode: HighlightableButtonNode {
let key: PeerInfoHeaderButtonKey
private let action: (PeerInfoHeaderButtonNode, ContextGesture?) -> Void
let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
//private let backgroundNode: NavigationBackgroundNode
private let contentNode: ASDisplayNode
private let iconNode: ASImageNode
private let textNode: ImmediateTextNode
private var animatedIcon: ComponentView<Empty>?
private var theme: PresentationTheme?
private var icon: PeerInfoHeaderButtonIcon?
private var isActive: Bool?
let backgroundContainerView: UIView
let backgroundView: UIView
init(key: PeerInfoHeaderButtonKey, action: @escaping (PeerInfoHeaderButtonNode, ContextGesture?) -> Void) {
self.key = key
self.action = action
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.backgroundContainerView = UIView()
self.backgroundView = UIView()
self.backgroundView.backgroundColor = .white
self.backgroundContainerView.addSubview(self.backgroundView)
/*self.backgroundNode = NavigationBackgroundNode(color: UIColor(white: 1.0, alpha: 0.2), enableBlur: true, enableSaturation: false)
self.backgroundNode.isUserInteractionEnabled = false*/
self.contentNode = ASDisplayNode()
self.contentNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.isUserInteractionEnabled = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
super.init()
self.accessibilityTraits = .button
self.containerNode.addSubnode(self.referenceNode)
//self.referenceNode.addSubnode(self.backgroundNode)
self.referenceNode.addSubnode(self.contentNode)
self.contentNode.addSubnode(self.iconNode)
self.addSubnode(self.containerNode)
self.contentNode.addSubnode(self.textNode)
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.layer.removeAnimation(forKey: "opacity")
strongSelf.alpha = 0.4
} else {
strongSelf.alpha = 1.0
strongSelf.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.containerNode.activated = { [weak self] gesture, _ in
if let strongSelf = self {
strongSelf.action(strongSelf, gesture)
}
}
self.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
@objc private func buttonPressed() {
switch self.icon {
case .voiceChat, .more, .leave:
if let animatedIconView = self.animatedIcon?.view as? LottieComponent.View {
animatedIconView.playOnce()
}
default:
break
}
self.action(self, nil)
}
func update(size: CGSize, text: String, icon: PeerInfoHeaderButtonIcon, isActive: Bool, presentationData: PresentationData, backgroundColor: UIColor, foregroundColor: UIColor, fraction: CGFloat, transition: ContainedViewLayoutTransition) {
let previousIcon = self.icon
let themeUpdated = self.theme != presentationData.theme
let iconUpdated = self.icon != icon
let isActiveUpdated = self.isActive != isActive
self.isActive = isActive
let iconSize = CGSize(width: 40.0, height: 40.0)
if themeUpdated || iconUpdated {
self.theme = presentationData.theme
self.icon = icon
var isGestureEnabled = false
if [.mute, .voiceChat, .more].contains(icon) {
isGestureEnabled = true
}
self.containerNode.isGestureEnabled = isGestureEnabled
let iconColor = UIColor.white
self.iconNode.image = generateImage(iconSize, contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.normal)
context.setFillColor(iconColor.cgColor)
let imageName: String?
switch icon {
case .message:
imageName = "Peer Info/ButtonMessage"
case .call:
imageName = "Peer Info/ButtonCall"
case .videoCall:
imageName = "Peer Info/ButtonVideo"
case .voiceChat:
imageName = nil
case .mute:
imageName = nil
case .unmute:
imageName = nil
case .more:
imageName = nil
case .addMember:
imageName = "Peer Info/ButtonAddMember"
case .search:
imageName = "Peer Info/ButtonSearch"
case .leave:
imageName = nil
case .stop:
imageName = "Peer Info/ButtonStop"
}
if let imageName = imageName, let image = generateTintedImage(image: UIImage(bundleImageName: imageName), color: .white) {
let imageRect = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
context.clip(to: imageRect, mask: image.cgImage!)
context.fill(imageRect)
}
})
}
let animationName: String?
var playOnce = false
var seekToEnd = false
switch icon {
case .voiceChat:
animationName = "anim_profilevc"
case .mute:
animationName = "anim_profileunmute"
if previousIcon == .unmute {
playOnce = true
} else {
seekToEnd = true
}
case .unmute:
animationName = "anim_profilemute"
if previousIcon == .mute {
playOnce = true
} else {
seekToEnd = true
}
case .more:
animationName = "anim_profilemore"
case .leave:
animationName = "anim_profileleave"
default:
animationName = nil
}
if let animationName = animationName {
let animatedIcon: ComponentView<Empty>
if let current = self.animatedIcon {
animatedIcon = current
} else {
animatedIcon = ComponentView()
self.animatedIcon = animatedIcon
}
let _ = animatedIcon.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: animationName),
color: foregroundColor,
startingPosition: seekToEnd ? .end : .begin
)),
environment: {},
containerSize: iconSize
)
} else if let animatedIcon = self.animatedIcon {
self.animatedIcon = nil
animatedIcon.view?.removeFromSuperview()
}
if let animatedIconView = self.animatedIcon?.view as? LottieComponent.View {
if animatedIconView.superview == nil {
self.contentNode.view.addSubview(animatedIconView)
}
if playOnce {
animatedIconView.playOnce()
}
}
transition.updateTintColor(layer: self.iconNode.layer, color: foregroundColor)
transition.updateTintColor(layer: self.titleNode.layer, color: foregroundColor)
transition.updateTintColor(layer: self.textNode.layer, color: foregroundColor)
if isActiveUpdated {
let alphaTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
alphaTransition.updateAlpha(node: self.iconNode, alpha: isActive ? 1.0 : 0.3)
if let animatedIconView = self.animatedIcon?.view {
alphaTransition.updateAlpha(layer: animatedIconView.layer, alpha: isActive ? 1.0 : 0.3)
}
alphaTransition.updateAlpha(node: self.textNode, alpha: isActive ? 1.0 : 0.3)
}
self.textNode.attributedText = NSAttributedString(string: text.lowercased(), font: Font.regular(11.0), textColor: .white)
self.accessibilityLabel = text
let titleSize = self.textNode.updateLayout(CGSize(width: 120.0, height: .greatestFiniteMagnitude))
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: size))
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height * 0.5 * (1.0 - fraction)), size: size))
transition.updateAlpha(node: self.contentNode, alpha: fraction)
let backgroundY: CGFloat = size.height * (1.0 - fraction)
let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: backgroundY), size: CGSize(width: size.width, height: max(0.0, size.height - backgroundY)))
//transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
transition.updateFrame(view: self.backgroundView, frame: backgroundFrame)
transition.updateSublayerTransformScale(node: self.contentNode, scale: 1.0 * fraction + 0.001 * (1.0 - fraction))
transition.updateCornerRadius(layer: self.backgroundView.layer, cornerRadius: min(16.0, backgroundFrame.height * 0.5))
//self.backgroundNode.update(size: backgroundFrame.size, cornerRadius: min(11.0, backgroundFrame.height * 0.5), transition: transition)
//self.backgroundNode.updateColor(color: backgroundColor, transition: transition)
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) / 2.0), y: 1.0), size: iconSize))
if let animatedIconView = self.animatedIcon?.view {
transition.updateFrame(view: animatedIconView, frame: CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) / 2.0), y: 1.0), size: iconSize))
}
transition.updateFrameAdditiveToCenter(node: self.textNode, frame: CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: size.height - titleSize.height - 9.0), size: titleSize))
self.referenceNode.frame = self.containerNode.bounds
}
}
@@ -0,0 +1,183 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramPresentationData
import AccountContext
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
final class PeerInfoHeaderEditingContentNode: ASDisplayNode {
private let context: AccountContext
private let requestUpdateLayout: () -> Void
var requestEditing: (() -> Void)?
let avatarNode: PeerInfoEditingAvatarNode
let avatarTextNode: ImmediateTextNode
let avatarButtonNode: HighlightableButtonNode
var itemNodes: [PeerInfoHeaderTextFieldNodeKey: PeerInfoHeaderTextFieldNode] = [:]
init(context: AccountContext, requestUpdateLayout: @escaping () -> Void) {
self.context = context
self.requestUpdateLayout = requestUpdateLayout
self.avatarNode = PeerInfoEditingAvatarNode(context: context)
self.avatarTextNode = ImmediateTextNode()
self.avatarButtonNode = HighlightableButtonNode()
super.init()
self.addSubnode(self.avatarNode)
self.avatarButtonNode.addSubnode(self.avatarTextNode)
self.avatarButtonNode.addTarget(self, action: #selector(textPressed), forControlEvents: .touchUpInside)
}
@objc private func textPressed() {
self.requestEditing?()
}
func editingTextForKey(_ key: PeerInfoHeaderTextFieldNodeKey) -> String? {
return self.itemNodes[key]?.text
}
func shakeTextForKey(_ key: PeerInfoHeaderTextFieldNodeKey) {
self.itemNodes[key]?.layer.addShakeAnimation()
}
func update(width: CGFloat, safeInset: CGFloat, statusBarHeight: CGFloat, navigationHeight: CGFloat, isModalOverlay: Bool, peer: Peer?, threadData: MessageHistoryThreadData?, chatLocation: ChatLocation, cachedData: CachedPeerData?, isContact: Bool, isSettings: Bool, presentationData: PresentationData, transition: ContainedViewLayoutTransition) -> CGFloat {
let avatarSize: CGFloat = isModalOverlay ? 200.0 : 100.0
let avatarFrame = CGRect(origin: CGPoint(x: floor((width - avatarSize) / 2.0), y: statusBarHeight + 22.0), size: CGSize(width: avatarSize, height: avatarSize))
transition.updateFrameAdditiveToCenter(node: self.avatarNode, frame: CGRect(origin: avatarFrame.center, size: CGSize()))
var contentHeight: CGFloat = statusBarHeight + 10.0 + avatarSize + 28.0
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {
if self.avatarButtonNode.supernode == nil {
self.addSubnode(self.avatarButtonNode)
}
self.avatarTextNode.attributedText = NSAttributedString(string: presentationData.strings.Settings_SetNewProfilePhotoOrVideo, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor)
self.avatarButtonNode.accessibilityLabel = self.avatarTextNode.attributedText?.string
let avatarTextSize = self.avatarTextNode.updateLayout(CGSize(width: width, height: 32.0))
transition.updateFrame(node: self.avatarTextNode, frame: CGRect(origin: CGPoint(), size: avatarTextSize))
transition.updateFrame(node: self.avatarButtonNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - avatarTextSize.width) / 2.0), y: contentHeight - 5.0), size: avatarTextSize))
contentHeight += 32.0
}
var isEditableBot = false
if let user = peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) {
isEditableBot = true
}
var fieldKeys: [PeerInfoHeaderTextFieldNodeKey] = []
if let user = peer as? TelegramUser {
if !user.isDeleted {
fieldKeys.append(.firstName)
if isEditableBot {
fieldKeys.append(.description)
} else if user.botInfo == nil {
fieldKeys.append(.lastName)
}
}
} else if let _ = peer as? TelegramGroup {
fieldKeys.append(.title)
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {
fieldKeys.append(.description)
}
} else if let _ = peer as? TelegramChannel {
fieldKeys.append(.title)
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {
fieldKeys.append(.description)
}
}
var hasPrevious = false
for key in fieldKeys {
let itemNode: PeerInfoHeaderTextFieldNode
var updateText: String?
if let current = self.itemNodes[key] {
itemNode = current
} else {
var isMultiline = false
switch key {
case .firstName:
if let peer = peer as? TelegramUser {
if let editableBotInfo = (cachedData as? CachedUserData)?.editableBotInfo {
updateText = editableBotInfo.name
} else {
updateText = peer.firstName ?? ""
}
}
case .lastName:
updateText = (peer as? TelegramUser)?.lastName ?? ""
case .title:
updateText = peer?.debugDisplayTitle ?? ""
case .description:
isMultiline = true
if let cachedData = cachedData as? CachedChannelData {
updateText = cachedData.about ?? ""
} else if let cachedData = cachedData as? CachedGroupData {
updateText = cachedData.about ?? ""
} else if let cachedData = cachedData as? CachedUserData {
if let editableBotInfo = cachedData.editableBotInfo {
updateText = editableBotInfo.about
} else {
updateText = cachedData.about ?? ""
}
} else {
updateText = ""
}
}
if isMultiline {
itemNode = PeerInfoHeaderMultiLineTextFieldNode(requestUpdateHeight: { [weak self] in
self?.requestUpdateLayout()
})
} else {
itemNode = PeerInfoHeaderSingleLineTextFieldNode()
}
self.itemNodes[key] = itemNode
self.addSubnode(itemNode)
}
let placeholder: String
var isEnabled = true
switch key {
case .firstName:
placeholder = isEditableBot ? presentationData.strings.UserInfo_BotNamePlaceholder : presentationData.strings.UserInfo_FirstNamePlaceholder
isEnabled = isContact || isSettings || isEditableBot
case .lastName:
placeholder = presentationData.strings.UserInfo_LastNamePlaceholder
isEnabled = isContact || isSettings
case .title:
if let channel = peer as? TelegramChannel, case .broadcast = channel.info {
placeholder = presentationData.strings.GroupInfo_ChannelListNamePlaceholder
} else {
placeholder = presentationData.strings.GroupInfo_GroupNamePlaceholder
}
isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)
case .description:
placeholder = presentationData.strings.Channel_Edit_AboutItem
isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) || isEditableBot
}
let itemHeight = itemNode.update(width: width, safeInset: safeInset, isSettings: isSettings, hasPrevious: hasPrevious, hasNext: key != fieldKeys.last, placeholder: placeholder, isEnabled: isEnabled, presentationData: presentationData, updateText: updateText)
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: itemHeight)))
contentHeight += itemHeight
hasPrevious = true
}
var removeKeys: [PeerInfoHeaderTextFieldNodeKey] = []
for (key, _) in self.itemNodes {
if !fieldKeys.contains(key) {
removeKeys.append(key)
}
}
for key in removeKeys {
if let itemNode = self.itemNodes.removeValue(forKey: key) {
itemNode.removeFromSupernode()
}
}
return contentHeight
}
}
@@ -0,0 +1,229 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import Display
import TelegramUIPreferences
final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderTextFieldNode, ASEditableTextNodeDelegate {
private let backgroundNode: ASDisplayNode
private let textNode: EditableTextNode
private let textNodeContainer: ASDisplayNode
private let measureTextNode: ImmediateTextNode
private let clearIconNode: ASImageNode
private let clearButtonNode: HighlightableButtonNode
private let topSeparator: ASDisplayNode
private let maskNode: ASImageNode
private let requestUpdateHeight: () -> Void
private var fontSize: PresentationFontSize?
private var theme: PresentationTheme?
private var currentParams: (width: CGFloat, safeInset: CGFloat)?
private var currentMeasuredHeight: CGFloat?
var text: String {
return self.textNode.attributedText?.string ?? ""
}
init(requestUpdateHeight: @escaping () -> Void) {
self.requestUpdateHeight = requestUpdateHeight
self.backgroundNode = ASDisplayNode()
self.textNode = EditableTextNode()
self.textNode.clipsToBounds = false
self.textNode.textView.clipsToBounds = false
self.textNode.textContainerInset = UIEdgeInsets()
self.textNodeContainer = ASDisplayNode()
self.measureTextNode = ImmediateTextNode()
self.measureTextNode.maximumNumberOfLines = 0
self.measureTextNode.isUserInteractionEnabled = false
self.measureTextNode.lineSpacing = 0.1
self.topSeparator = ASDisplayNode()
self.clearIconNode = ASImageNode()
self.clearIconNode.isLayerBacked = true
self.clearIconNode.displayWithoutProcessing = true
self.clearIconNode.displaysAsynchronously = false
self.clearIconNode.isHidden = true
self.clearButtonNode = HighlightableButtonNode()
self.clearButtonNode.isHidden = true
self.clearButtonNode.isAccessibilityElement = false
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.backgroundNode)
self.textNodeContainer.addSubnode(self.textNode)
self.addSubnode(self.textNodeContainer)
self.addSubnode(self.clearIconNode)
self.addSubnode(self.clearButtonNode)
self.addSubnode(self.topSeparator)
self.addSubnode(self.maskNode)
self.clearButtonNode.addTarget(self, action: #selector(self.clearButtonPressed), forControlEvents: .touchUpInside)
self.clearButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.clearIconNode.layer.removeAnimation(forKey: "opacity")
strongSelf.clearIconNode.alpha = 0.4
} else {
strongSelf.clearIconNode.alpha = 1.0
strongSelf.clearIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
@objc private func clearButtonPressed() {
guard let theme = self.theme else {
return
}
let font: UIFont
if let fontSize = self.fontSize {
font = Font.regular(fontSize.itemListBaseFontSize)
} else {
font = Font.regular(17.0)
}
let attributedText = NSAttributedString(string: "", font: font, textColor: theme.list.itemPrimaryTextColor)
self.textNode.attributedText = attributedText
self.requestUpdateHeight()
self.updateClearButtonVisibility()
}
func update(width: CGFloat, safeInset: CGFloat, isSettings: Bool, hasPrevious: Bool, hasNext: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat {
self.currentParams = (width, safeInset)
self.fontSize = presentationData.listsFontSize
let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize)
if self.theme !== presentationData.theme {
self.theme = presentationData.theme
self.backgroundNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
let textColor = presentationData.theme.list.itemPrimaryTextColor
self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: titleFont, NSAttributedString.Key.foregroundColor.rawValue: textColor]
self.textNode.keyboardAppearance = presentationData.theme.rootController.keyboardColor.keyboardAppearance
self.textNode.tintColor = presentationData.theme.list.itemAccentColor
self.textNode.clipsToBounds = true
self.textNode.delegate = self
self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
self.clearIconNode.image = PresentationResourcesItemList.itemListClearInputIcon(presentationData.theme)
}
self.topSeparator.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let separatorRightInset: CGFloat = 16.0
let separatorX = safeInset + (hasPrevious ? 16.0 : 0.0)
self.topSeparator.frame = CGRect(origin: CGPoint(x: separatorX, y: 0.0), size: CGSize(width: width - separatorX - safeInset - separatorRightInset, height: UIScreenPixel))
let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor)
if self.textNode.attributedPlaceholderText == nil || !self.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) {
self.textNode.attributedPlaceholderText = attributedPlaceholderText
}
if let updateText = updateText {
let attributedText = NSAttributedString(string: updateText, font: titleFont, textColor: presentationData.theme.list.itemPrimaryTextColor)
self.textNode.attributedText = attributedText
}
var measureText = self.textNode.attributedText?.string ?? ""
if measureText.hasSuffix("\n") || measureText.isEmpty {
measureText += "|"
}
let attributedMeasureText = NSAttributedString(string: measureText, font: titleFont, textColor: .gray)
self.measureTextNode.attributedText = attributedMeasureText
let measureTextSize = self.measureTextNode.updateLayout(CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: .greatestFiniteMagnitude))
self.measureTextNode.frame = CGRect(origin: CGPoint(), size: measureTextSize)
self.currentMeasuredHeight = measureTextSize.height
let verticalInset: CGFloat = 15.0
let height = measureTextSize.height + verticalInset * 2.0
let buttonSize = CGSize(width: 38.0, height: height)
self.clearButtonNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width, y: 0.0), size: buttonSize)
if let image = self.clearIconNode.image {
self.clearIconNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width + floor((buttonSize.width - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size)
}
let textNodeFrame = CGRect(origin: CGPoint(x: safeInset + 16.0, y: 15.0), size: CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: max(height, 1000.0)))
self.textNodeContainer.frame = textNodeFrame
self.textNode.frame = CGRect(origin: CGPoint(), size: textNodeFrame.size)
self.backgroundNode.frame = CGRect(origin: CGPoint(x: safeInset, y: 0.0), size: CGSize(width: max(1.0, width - safeInset * 2.0), height: height))
let hasCorners = safeInset > 0.0 && (!hasPrevious || !hasNext)
let hasTopCorners = hasCorners && !hasPrevious
let hasBottomCorners = hasCorners && !hasNext
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInset, y: 0.0), size: CGSize(width: width - safeInset - safeInset, height: height))
return height
}
func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) {
self.updateClearButtonVisibility()
}
func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) {
self.updateClearButtonVisibility()
}
private func updateClearButtonVisibility() {
let isHidden = !self.textNode.isFirstResponder() || self.text.isEmpty
self.clearIconNode.isHidden = isHidden
self.clearButtonNode.isHidden = isHidden
self.clearButtonNode.isAccessibilityElement = isHidden
}
func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
guard let theme = self.theme else {
return true
}
let updatedText = (editableTextNode.textView.text as NSString).replacingCharacters(in: range, with: text)
if updatedText.count > 255 {
let attributedText = NSAttributedString(string: String(updatedText[updatedText.startIndex..<updatedText.index(updatedText.startIndex, offsetBy: 255)]), font: Font.regular(17.0), textColor: theme.list.itemPrimaryTextColor)
self.textNode.attributedText = attributedText
self.requestUpdateHeight()
return false
} else {
return true
}
}
func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) {
if let (width, safeInset) = self.currentParams {
var measureText = self.textNode.attributedText?.string ?? ""
if measureText.hasSuffix("\n") || measureText.isEmpty {
measureText += "|"
}
let attributedMeasureText = NSAttributedString(string: measureText, font: Font.regular(17.0), textColor: .black)
self.measureTextNode.attributedText = attributedMeasureText
let measureTextSize = self.measureTextNode.updateLayout(CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: .greatestFiniteMagnitude))
if let currentMeasuredHeight = self.currentMeasuredHeight, abs(measureTextSize.height - currentMeasuredHeight) > 0.1 {
self.requestUpdateHeight()
}
}
}
func editableTextNodeShouldPaste(_ editableTextNode: ASEditableTextNode) -> Bool {
let text: String? = UIPasteboard.general.string
if let _ = text {
return true
} else {
return false
}
}
}
@@ -0,0 +1,426 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import ManagedAnimationNode
import Display
private enum MoreIconNodeState: Equatable {
case more
case search
case sort
case moreToSearch(Float)
}
private final class MoreIconNode: ManagedAnimationNode {
private let duration: Double = 0.21
private var iconState: MoreIconNodeState = .more
init() {
super.init(size: CGSize(width: 30.0, height: 30.0))
self.trackTo(item: ManagedAnimationItem(source: .local("anim_moretosearch"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.0))
}
func play() {
if case .more = self.iconState {
self.trackTo(item: ManagedAnimationItem(source: .local("anim_moredots"), frames: .range(startFrame: 0, endFrame: 46), duration: 0.76))
}
}
func enqueueState(_ state: MoreIconNodeState, animated: Bool) {
guard self.iconState != state else {
return
}
let previousState = self.iconState
self.iconState = state
var source: ManagedAnimationSource = .local("anim_moretosearch")
let totalLength: Int = 90
if animated {
switch previousState {
case .more:
switch state {
case .more:
break
case .search:
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: totalLength), duration: self.duration))
case .sort:
source = .local("anim_moretosort_l")
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: totalLength), duration: self.duration))
case let .moreToSearch(progress):
let frame = Int(progress * Float(totalLength))
let duration = self.duration * Double(progress)
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: frame), duration: duration))
}
case .search:
switch state {
case .more:
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: 0), duration: self.duration))
case .search:
break
case .sort:
source = .local("anim_sorttosearch")
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: 0), duration: self.duration))
case let .moreToSearch(progress):
let frame = Int(progress * Float(totalLength))
let duration = self.duration * Double((1.0 - progress))
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: frame), duration: duration))
}
case .sort:
switch state {
case .more:
source = .local("anim_moretosort_l")
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: 0), duration: self.duration))
case .search:
source = .local("anim_sorttosearch")
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: totalLength), duration: self.duration))
case .sort:
break
case let .moreToSearch(progress):
let frame = Int(progress * Float(totalLength))
let duration = self.duration * Double((1.0 - progress))
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: frame), duration: duration))
}
case let .moreToSearch(currentProgress):
let currentFrame = Int(currentProgress * Float(totalLength))
switch state {
case .more:
let duration = self.duration * Double(currentProgress)
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: currentFrame, endFrame: 0), duration: duration))
case .search:
let duration = self.duration * (1.0 - Double(currentProgress))
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: currentFrame, endFrame: totalLength), duration: duration))
case .sort:
break
case let .moreToSearch(progress):
let frame = Int(progress * Float(totalLength))
let duration = self.duration * Double(abs(currentProgress - progress))
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: currentFrame, endFrame: frame), duration: duration))
}
}
} else {
switch state {
case .more:
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: 0, endFrame: 0), duration: 0.0))
case .search:
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: totalLength), duration: 0.0))
case .sort:
source = .local("anim_moretosort_l")
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: totalLength, endFrame: totalLength), duration: 0.0))
case let .moreToSearch(progress):
let frame = Int(progress * Float(totalLength))
self.trackTo(item: ManagedAnimationItem(source: source, frames: .range(startFrame: frame, endFrame: frame), duration: 0.0))
}
}
}
}
final class PeerInfoHeaderNavigationButton: HighlightableButtonNode {
let containerNode: ContextControllerSourceNode
let contextSourceNode: ContextReferenceContentNode
private let textNode: ImmediateTextNode
private let iconNode: ASImageNode
private let backIconLayer: SimpleShapeLayer
private var animationNode: MoreIconNode?
private let backgroundNode: NavigationBackgroundNode
private var key: PeerInfoHeaderNavigationButtonKey?
private var contentsColor: UIColor = .white
private var canBeExpanded: Bool = false
var action: ((ASDisplayNode, ContextGesture?) -> Void)?
init() {
self.contextSourceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.textNode = ImmediateTextNode()
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.backIconLayer = SimpleShapeLayer()
self.backIconLayer.lineWidth = 3.0
self.backIconLayer.lineCap = .round
self.backIconLayer.lineJoin = .round
self.backIconLayer.strokeColor = UIColor.white.cgColor
self.backIconLayer.fillColor = nil
self.backIconLayer.isHidden = true
self.backIconLayer.path = try? convertSvgPath("M10.5,2 L1.5,11 L10.5,20 ")
self.backgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: true)
super.init(pointerStyle: .insetRectangle(-8.0, 2.0))
self.isAccessibilityElement = true
self.accessibilityTraits = .button
self.containerNode.addSubnode(self.contextSourceNode)
self.contextSourceNode.addSubnode(self.backgroundNode)
self.contextSourceNode.addSubnode(self.textNode)
self.contextSourceNode.addSubnode(self.iconNode)
self.contextSourceNode.layer.addSublayer(self.backIconLayer)
self.addSubnode(self.containerNode)
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.action?(strongSelf.contextSourceNode, gesture)
}
self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
if isReduceTransparencyEnabled() {
self.backgroundNode.isHidden = true
}
}
@objc private func pressed() {
self.animationNode?.play()
self.action?(self.contextSourceNode, nil)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var boundingRect = self.bounds
if self.textNode.alpha != 0.0 {
boundingRect = boundingRect.union(self.textNode.frame)
}
boundingRect = boundingRect.insetBy(dx: -8.0, dy: -4.0)
if boundingRect.contains(point) {
return super.hitTest(self.bounds.center, with: event)
} else {
return nil
}
}
func updateContentsColor(backgroundColor: UIColor, contentsColor: UIColor, canBeExpanded: Bool, transition: ContainedViewLayoutTransition) {
self.contentsColor = contentsColor
self.canBeExpanded = canBeExpanded
self.backgroundNode.updateColor(color: backgroundColor, transition: transition)
transition.updateTintColor(layer: self.textNode.layer, color: self.contentsColor)
transition.updateTintColor(view: self.iconNode.view, color: self.contentsColor)
transition.updateStrokeColor(layer: self.backIconLayer, strokeColor: self.contentsColor)
switch self.key {
case .back:
transition.updateAlpha(layer: self.textNode.layer, alpha: canBeExpanded ? 1.0 : 0.0)
transition.updateTransformScale(node: self.textNode, scale: canBeExpanded ? 1.0 : 0.001)
var iconTransform = CATransform3DIdentity
iconTransform = CATransform3DScale(iconTransform, canBeExpanded ? 1.0 : 0.8, canBeExpanded ? 1.0 : 0.8, 1.0)
iconTransform = CATransform3DTranslate(iconTransform, canBeExpanded ? -7.0 : 0.0, 0.0, 0.0)
transition.updateTransform(node: self.iconNode, transform: CATransform3DGetAffineTransform(iconTransform))
transition.updateTransform(layer: self.backIconLayer, transform: CATransform3DGetAffineTransform(iconTransform))
transition.updateLineWidth(layer: self.backIconLayer, lineWidth: canBeExpanded ? 3.0 : 2.075)
default:
break
}
if let animationNode = self.animationNode {
transition.updateTintColor(layer: animationNode.imageNode.layer, color: self.contentsColor)
}
}
func update(key: PeerInfoHeaderNavigationButtonKey, presentationData: PresentationData, height: CGFloat) -> CGSize {
let transition: ContainedViewLayoutTransition = .immediate
var iconOffset = CGPoint()
switch key {
case .back:
iconOffset = CGPoint(x: -1.0, y: 0.0)
default:
break
}
let textSize: CGSize
let isFirstTime = self.key == nil
if self.key != key {
self.key = key
let text: String
var accessibilityText: String
var icon: UIImage?
var isBold = false
var isGestureEnabled = false
var isAnimation = false
var animationState: MoreIconNodeState = .more
switch key {
case .back:
text = presentationData.strings.Common_Back
accessibilityText = presentationData.strings.Common_Back
icon = NavigationBar.backArrowImage(color: .white)
case .edit:
text = presentationData.strings.Common_Edit
accessibilityText = text
case .cancel:
text = presentationData.strings.Common_Cancel
accessibilityText = text
isBold = false
case .done, .selectionDone:
text = presentationData.strings.Common_Done
accessibilityText = text
isBold = true
case .select:
text = presentationData.strings.Common_Select
accessibilityText = text
case .search:
text = ""
accessibilityText = presentationData.strings.Common_Search
icon = nil
isAnimation = true
animationState = .search
case .standaloneSearch:
text = ""
accessibilityText = presentationData.strings.Common_Search
icon = PresentationResourcesRootController.navigationCompactSearchWhiteIcon(presentationData.theme)
case .searchWithTags:
text = ""
accessibilityText = presentationData.strings.Common_Search
icon = PresentationResourcesRootController.navigationCompactTagsSearchWhiteIcon(presentationData.theme)
case .editPhoto:
text = presentationData.strings.Settings_EditPhoto
accessibilityText = text
case .editVideo:
text = presentationData.strings.Settings_EditVideo
accessibilityText = text
case .more:
text = ""
accessibilityText = presentationData.strings.Common_More
icon = nil// PresentationResourcesRootController.navigationMoreCircledIcon(presentationData.theme)
isGestureEnabled = true
isAnimation = true
animationState = .more
case .qrCode:
text = ""
accessibilityText = presentationData.strings.PeerInfo_QRCode_Title
icon = PresentationResourcesRootController.navigationQrCodeIcon(presentationData.theme)
case .moreSearchSort:
text = ""
accessibilityText = ""
case .postStory:
text = ""
accessibilityText = presentationData.strings.Story_Privacy_PostStory
icon = PresentationResourcesRootController.navigationPostStoryIcon(presentationData.theme)
case .sort:
text = ""
accessibilityText = presentationData.strings.Common_More
icon = PresentationResourcesRootController.navigationSortIcon(presentationData.theme)
isAnimation = true
animationState = .sort
}
self.accessibilityLabel = accessibilityText
self.containerNode.isGestureEnabled = isGestureEnabled
let font: UIFont = isBold ? Font.semibold(17.0) : Font.regular(17.0)
self.textNode.attributedText = NSAttributedString(string: text, font: font, textColor: .white)
transition.updateTintColor(layer: self.textNode.layer, color: self.contentsColor)
self.iconNode.image = icon
transition.updateTintColor(view: self.iconNode.view, color: self.contentsColor)
if isAnimation {
self.iconNode.isHidden = true
let animationNode: MoreIconNode
if let current = self.animationNode {
animationNode = current
} else {
animationNode = MoreIconNode()
self.animationNode = animationNode
self.contextSourceNode.addSubnode(animationNode)
animationNode.imageNode.layer.layerTintColor = self.contentsColor.cgColor
animationNode.customColor = .white
}
transition.updateTintColor(layer: animationNode.imageNode.layer, color: self.contentsColor)
animationNode.enqueueState(animationState, animated: !isFirstTime)
} else {
self.iconNode.isHidden = false
if let current = self.animationNode {
self.animationNode = nil
current.removeFromSupernode()
}
}
textSize = self.textNode.updateLayout(CGSize(width: 200.0, height: .greatestFiniteMagnitude))
} else {
textSize = self.textNode.bounds.size
}
let inset: CGFloat = 0.0
var textInset: CGFloat = 0.0
switch key {
case .back:
textInset += 11.0
default:
break
}
let resultSize: CGSize
let textFrame = CGRect(origin: CGPoint(x: inset + textInset, y: floor((height - textSize.height) / 2.0)), size: textSize)
self.textNode.position = textFrame.center
self.textNode.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
if let animationNode = self.animationNode {
let animationSize = CGSize(width: 30.0, height: 30.0)
animationNode.frame = CGRect(origin: CGPoint(x: inset, y: floor((height - animationSize.height) / 2.0)), size: animationSize).offsetBy(dx: iconOffset.x, dy: iconOffset.y)
let size = CGSize(width: animationSize.width + inset * 2.0, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: size)
resultSize = size
} else if let image = self.iconNode.image {
let iconFrame = CGRect(origin: CGPoint(x: inset, y: floor((height - image.size.height) / 2.0)), size: image.size).offsetBy(dx: iconOffset.x, dy: iconOffset.y)
self.iconNode.position = iconFrame.center
self.iconNode.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
if case .back = key {
self.backIconLayer.position = iconFrame.center
self.backIconLayer.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
self.iconNode.isHidden = true
self.backIconLayer.isHidden = false
} else {
self.iconNode.isHidden = false
self.backIconLayer.isHidden = true
}
let size = CGSize(width: image.size.width + inset * 2.0, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: size)
resultSize = size
} else {
let size = CGSize(width: textSize.width + inset * 2.0, height: height)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
self.contextSourceNode.frame = CGRect(origin: CGPoint(), size: size)
resultSize = size
}
let diameter: CGFloat = 32.0
let backgroundWidth: CGFloat
if self.iconNode.image != nil || self.animationNode != nil {
backgroundWidth = diameter
} else {
backgroundWidth = max(diameter, resultSize.width + 12.0 * 2.0)
}
let backgroundFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - backgroundWidth) * 0.5), y: floor((resultSize.height - diameter) * 0.5)), size: CGSize(width: backgroundWidth, height: diameter))
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
self.backgroundNode.update(size: backgroundFrame.size, cornerRadius: diameter * 0.5, transition: transition)
self.hitTestSlop = UIEdgeInsets(top: -2.0, left: -12.0, bottom: -2.0, right: -12.0)
return resultSize
}
}
@@ -0,0 +1,309 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import Display
enum PeerInfoHeaderNavigationButtonKey {
case back
case edit
case done
case cancel
case select
case selectionDone
case search
case searchWithTags
case standaloneSearch
case editPhoto
case editVideo
case more
case sort
case qrCode
case moreSearchSort
case postStory
}
struct PeerInfoHeaderNavigationButtonSpec: Equatable {
let key: PeerInfoHeaderNavigationButtonKey
let isForExpandedView: Bool
}
final class PeerInfoHeaderNavigationButtonContainerNode: SparseNode {
private var presentationData: PresentationData?
private(set) var leftButtonNodes: [PeerInfoHeaderNavigationButtonKey: PeerInfoHeaderNavigationButton] = [:]
private(set) var rightButtonNodes: [PeerInfoHeaderNavigationButtonKey: PeerInfoHeaderNavigationButton] = [:]
private var currentLeftButtons: [PeerInfoHeaderNavigationButtonSpec] = []
private var currentRightButtons: [PeerInfoHeaderNavigationButtonSpec] = []
private var backgroundContentColor: UIColor = .clear
private var contentsColor: UIColor = .white
private var canBeExpanded: Bool = false
var performAction: ((PeerInfoHeaderNavigationButtonKey, ContextReferenceContentNode?, ContextGesture?) -> Void)?
func updateContentsColor(backgroundContentColor: UIColor, contentsColor: UIColor, canBeExpanded: Bool, transition: ContainedViewLayoutTransition) {
self.backgroundContentColor = backgroundContentColor
self.contentsColor = contentsColor
self.canBeExpanded = canBeExpanded
for (_, button) in self.leftButtonNodes {
button.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: canBeExpanded, transition: transition)
transition.updateSublayerTransformOffset(layer: button.layer, offset: CGPoint(x: canBeExpanded ? -8.0 : 0.0, y: 0.0))
}
var accumulatedRightButtonOffset: CGFloat = canBeExpanded ? 16.0 : 0.0
for spec in self.currentRightButtons.reversed() {
guard let button = self.rightButtonNodes[spec.key] else {
continue
}
button.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: canBeExpanded, transition: transition)
if !spec.isForExpandedView {
transition.updateSublayerTransformOffset(layer: button.layer, offset: CGPoint(x: accumulatedRightButtonOffset, y: 0.0))
if self.backgroundContentColor.alpha != 0.0 {
accumulatedRightButtonOffset -= 6.0
}
}
}
for (key, button) in self.rightButtonNodes {
if !self.currentRightButtons.contains(where: { $0.key == key }) {
button.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: canBeExpanded, transition: transition)
transition.updateSublayerTransformOffset(layer: button.layer, offset: CGPoint(x: 0.0, y: 0.0))
}
}
}
func update(size: CGSize, presentationData: PresentationData, leftButtons: [PeerInfoHeaderNavigationButtonSpec], rightButtons: [PeerInfoHeaderNavigationButtonSpec], expandFraction: CGFloat, shouldAnimateIn: Bool, transition: ContainedViewLayoutTransition) {
let sideInset: CGFloat = 24.0
let expandedSideInset: CGFloat = 16.0
let maximumExpandOffset: CGFloat = 14.0
let expandOffset: CGFloat = -expandFraction * maximumExpandOffset
if self.currentLeftButtons != leftButtons || presentationData.strings !== self.presentationData?.strings {
self.currentLeftButtons = leftButtons
var nextRegularButtonOrigin = sideInset
var nextExpandedButtonOrigin = sideInset
for spec in leftButtons.reversed() {
let buttonNode: PeerInfoHeaderNavigationButton
var wasAdded = false
if let current = self.leftButtonNodes[spec.key] {
buttonNode = current
} else {
wasAdded = true
buttonNode = PeerInfoHeaderNavigationButton()
self.leftButtonNodes[spec.key] = buttonNode
self.addSubnode(buttonNode)
buttonNode.action = { [weak self] _, gesture in
guard let strongSelf = self, let buttonNode = strongSelf.leftButtonNodes[spec.key] else {
return
}
strongSelf.performAction?(spec.key, buttonNode.contextSourceNode, gesture)
}
}
let buttonSize = buttonNode.update(key: spec.key, presentationData: presentationData, height: size.height)
var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin
let buttonY: CGFloat
if case .back = spec.key {
buttonY = 0.0
} else {
buttonY = expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)
}
let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin, y: buttonY), size: buttonSize)
nextButtonOrigin += buttonSize.width + 4.0
if spec.isForExpandedView {
nextExpandedButtonOrigin = nextButtonOrigin
} else {
nextRegularButtonOrigin = nextButtonOrigin
}
let alphaFactor: CGFloat
if case .back = spec.key {
alphaFactor = 1.0
} else {
alphaFactor = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction)
}
if wasAdded {
buttonNode.frame = buttonFrame
buttonNode.alpha = 0.0
transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
buttonNode.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: self.canBeExpanded, transition: .immediate)
transition.updateSublayerTransformOffset(layer: buttonNode.layer, offset: CGPoint(x: canBeExpanded ? -8.0 : 0.0, y: 0.0))
} else {
transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame)
transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
}
}
var removeKeys: [PeerInfoHeaderNavigationButtonKey] = []
for (key, _) in self.leftButtonNodes {
if !leftButtons.contains(where: { $0.key == key }) {
removeKeys.append(key)
}
}
for key in removeKeys {
if let buttonNode = self.leftButtonNodes.removeValue(forKey: key) {
buttonNode.removeFromSupernode()
}
}
} else {
var nextRegularButtonOrigin = sideInset
var nextExpandedButtonOrigin = sideInset
for spec in leftButtons.reversed() {
if let buttonNode = self.leftButtonNodes[spec.key] {
let buttonSize = buttonNode.bounds.size
var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin
let buttonY: CGFloat
if case .back = spec.key {
buttonY = 0.0
} else {
buttonY = expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)
}
let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin, y: buttonY), size: buttonSize)
nextButtonOrigin += buttonSize.width + 4.0
if spec.isForExpandedView {
nextExpandedButtonOrigin = nextButtonOrigin
} else {
nextRegularButtonOrigin = nextButtonOrigin
}
transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame)
let alphaFactor: CGFloat
if case .back = spec.key {
alphaFactor = 1.0
} else {
alphaFactor = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction)
}
var buttonTransition = transition
if case let .animated(duration, curve) = buttonTransition, alphaFactor == 0.0 {
buttonTransition = .animated(duration: duration * 0.25, curve: curve)
}
buttonTransition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
}
}
}
var accumulatedRightButtonOffset: CGFloat = self.canBeExpanded ? 16.0 : 0.0
if self.currentRightButtons != rightButtons || presentationData.strings !== self.presentationData?.strings {
self.currentRightButtons = rightButtons
var nextRegularButtonOrigin = size.width - sideInset - 8.0
var nextExpandedButtonOrigin = size.width - expandedSideInset
for spec in rightButtons.reversed() {
let buttonNode: PeerInfoHeaderNavigationButton
var wasAdded = false
var key = spec.key
if key == .more || key == .search || key == .sort {
key = .moreSearchSort
}
if let current = self.rightButtonNodes[key] {
buttonNode = current
} else {
wasAdded = true
buttonNode = PeerInfoHeaderNavigationButton()
self.rightButtonNodes[key] = buttonNode
self.addSubnode(buttonNode)
}
buttonNode.action = { [weak self] _, gesture in
guard let strongSelf = self, let buttonNode = strongSelf.rightButtonNodes[key] else {
return
}
strongSelf.performAction?(spec.key, buttonNode.contextSourceNode, gesture)
}
let buttonSize = buttonNode.update(key: spec.key, presentationData: presentationData, height: size.height)
var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin
let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin - buttonSize.width, y: expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)), size: buttonSize)
nextButtonOrigin -= buttonSize.width + 15.0
if spec.isForExpandedView {
nextExpandedButtonOrigin = nextButtonOrigin
} else {
nextRegularButtonOrigin = nextButtonOrigin
}
let alphaFactor: CGFloat = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction)
if wasAdded {
buttonNode.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: self.canBeExpanded, transition: .immediate)
if shouldAnimateIn {
if key == .moreSearchSort || key == .searchWithTags || key == .standaloneSearch {
buttonNode.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
}
}
buttonNode.frame = buttonFrame
buttonNode.alpha = 0.0
transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
if !spec.isForExpandedView {
transition.updateSublayerTransformOffset(layer: buttonNode.layer, offset: CGPoint(x: accumulatedRightButtonOffset, y: 0.0))
if self.backgroundContentColor.alpha != 0.0 {
accumulatedRightButtonOffset -= 6.0
}
} else {
transition.updateSublayerTransformOffset(layer: buttonNode.layer, offset: .zero)
}
} else {
transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame)
transition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
}
}
var removeKeys: [PeerInfoHeaderNavigationButtonKey] = []
for (key, _) in self.rightButtonNodes {
if key == .moreSearchSort {
if !rightButtons.contains(where: { $0.key == .more || $0.key == .search || $0.key == .sort }) {
removeKeys.append(key)
}
} else if !rightButtons.contains(where: { $0.key == key }) {
removeKeys.append(key)
}
}
for key in removeKeys {
if let buttonNode = self.rightButtonNodes.removeValue(forKey: key) {
if key == .moreSearchSort || key == .searchWithTags || key == .standaloneSearch {
buttonNode.layer.animateAlpha(from: buttonNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak buttonNode] _ in
buttonNode?.removeFromSupernode()
})
buttonNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
} else {
buttonNode.removeFromSupernode()
}
}
}
} else {
var nextRegularButtonOrigin = size.width - sideInset - 8.0
var nextExpandedButtonOrigin = size.width - expandedSideInset
for spec in rightButtons.reversed() {
var key = spec.key
if key == .more || key == .search || key == .sort {
key = .moreSearchSort
}
if let buttonNode = self.rightButtonNodes[key] {
let buttonSize = buttonNode.bounds.size
var nextButtonOrigin = spec.isForExpandedView ? nextExpandedButtonOrigin : nextRegularButtonOrigin
let buttonFrame = CGRect(origin: CGPoint(x: nextButtonOrigin - buttonSize.width, y: expandOffset + (spec.isForExpandedView ? maximumExpandOffset : 0.0)), size: buttonSize)
nextButtonOrigin -= buttonSize.width + 15.0
if spec.isForExpandedView {
nextExpandedButtonOrigin = nextButtonOrigin
} else {
nextRegularButtonOrigin = nextButtonOrigin
}
transition.updateFrameAdditiveToCenter(node: buttonNode, frame: buttonFrame)
let alphaFactor: CGFloat = spec.isForExpandedView ? expandFraction : (1.0 - expandFraction)
var buttonTransition = transition
if case let .animated(duration, curve) = buttonTransition, alphaFactor == 0.0 {
buttonTransition = .animated(duration: duration * 0.25, curve: curve)
}
buttonTransition.updateAlpha(node: buttonNode, alpha: alphaFactor * alphaFactor)
}
}
}
self.presentationData = presentationData
}
}
@@ -0,0 +1,154 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import TelegramPresentationData
import Display
final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeaderTextFieldNode, UITextFieldDelegate {
private let backgroundNode: ASDisplayNode
private let textNode: TextFieldNode
private let measureTextNode: ImmediateTextNode
private let clearIconNode: ASImageNode
private let clearButtonNode: HighlightableButtonNode
private let topSeparator: ASDisplayNode
private let maskNode: ASImageNode
private var theme: PresentationTheme?
var text: String {
return self.textNode.textField.text ?? ""
}
override init() {
self.backgroundNode = ASDisplayNode()
self.textNode = TextFieldNode()
self.measureTextNode = ImmediateTextNode()
self.measureTextNode.maximumNumberOfLines = 0
self.clearIconNode = ASImageNode()
self.clearIconNode.isLayerBacked = true
self.clearIconNode.displayWithoutProcessing = true
self.clearIconNode.displaysAsynchronously = false
self.clearIconNode.isHidden = true
self.clearButtonNode = HighlightableButtonNode()
self.clearButtonNode.isHidden = true
self.clearButtonNode.isAccessibilityElement = false
self.topSeparator = ASDisplayNode()
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textNode)
self.addSubnode(self.clearIconNode)
self.addSubnode(self.clearButtonNode)
self.addSubnode(self.topSeparator)
self.addSubnode(self.maskNode)
self.textNode.textField.delegate = self
self.clearButtonNode.addTarget(self, action: #selector(self.clearButtonPressed), forControlEvents: .touchUpInside)
self.clearButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.clearIconNode.layer.removeAnimation(forKey: "opacity")
strongSelf.clearIconNode.alpha = 0.4
} else {
strongSelf.clearIconNode.alpha = 1.0
strongSelf.clearIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
@objc private func clearButtonPressed() {
self.textNode.textField.text = ""
self.updateClearButtonVisibility()
}
@objc func textFieldDidBeginEditing(_ textField: UITextField) {
self.updateClearButtonVisibility()
}
@objc func textFieldDidEndEditing(_ textField: UITextField) {
self.updateClearButtonVisibility()
}
private func updateClearButtonVisibility() {
let isHidden = !self.textNode.textField.isFirstResponder || self.text.isEmpty
self.clearIconNode.isHidden = isHidden
self.clearButtonNode.isHidden = isHidden
self.clearButtonNode.isAccessibilityElement = isHidden
}
func update(width: CGFloat, safeInset: CGFloat, isSettings: Bool, hasPrevious: Bool, hasNext: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat {
let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize)
self.textNode.textField.font = titleFont
if self.theme !== presentationData.theme {
self.theme = presentationData.theme
self.backgroundNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
self.textNode.textField.textColor = presentationData.theme.list.itemPrimaryTextColor
self.textNode.textField.keyboardAppearance = presentationData.theme.rootController.keyboardColor.keyboardAppearance
self.textNode.textField.tintColor = presentationData.theme.list.itemAccentColor
self.clearIconNode.image = PresentationResourcesItemList.itemListClearInputIcon(presentationData.theme)
}
let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor)
if self.textNode.textField.attributedPlaceholder == nil || !self.textNode.textField.attributedPlaceholder!.isEqual(to: attributedPlaceholderText) {
self.textNode.textField.attributedPlaceholder = attributedPlaceholderText
self.textNode.textField.accessibilityHint = attributedPlaceholderText.string
}
if let updateText = updateText {
self.textNode.textField.text = updateText
}
let separatorRightInset: CGFloat = 16.0
if !hasPrevious {
self.topSeparator.isHidden = true
}
self.topSeparator.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let separatorX = safeInset + (hasPrevious ? 16.0 : 0.0)
self.topSeparator.frame = CGRect(origin: CGPoint(x: separatorX, y: 0.0), size: CGSize(width: width - separatorX - safeInset - separatorRightInset, height: UIScreenPixel))
let measureText = "|"
let attributedMeasureText = NSAttributedString(string: measureText, font: titleFont, textColor: .black)
self.measureTextNode.attributedText = attributedMeasureText
let measureTextSize = self.measureTextNode.updateLayout(CGSize(width: width - safeInset * 2.0 - 16.0 * 2.0 - 38.0, height: .greatestFiniteMagnitude))
let verticalInset: CGFloat = 15.0
let height = measureTextSize.height + verticalInset * 2.0
let buttonSize = CGSize(width: 38.0, height: height)
self.clearButtonNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width, y: 0.0), size: buttonSize)
if let image = self.clearIconNode.image {
self.clearIconNode.frame = CGRect(origin: CGPoint(x: width - safeInset - buttonSize.width + floor((buttonSize.width - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size)
}
self.backgroundNode.frame = CGRect(origin: CGPoint(x: safeInset, y: 0.0), size: CGSize(width: max(1.0, width - safeInset * 2.0), height: height))
self.textNode.frame = CGRect(origin: CGPoint(x: safeInset + 16.0, y: floor((height - 40.0) / 2.0)), size: CGSize(width: max(1.0, width - safeInset * 2.0 - 16.0 * 2.0 - 38.0), height: 40.0))
let hasCorners = safeInset > 0.0 && (!hasPrevious || !hasNext)
let hasTopCorners = hasCorners && !hasPrevious
let hasBottomCorners = hasCorners && !hasNext
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
self.maskNode.frame = CGRect(origin: CGPoint(x: safeInset, y: 0.0), size: CGSize(width: width - safeInset - safeInset, height: height))
self.textNode.isUserInteractionEnabled = isEnabled
self.textNode.alpha = isEnabled ? 1.0 : 0.6
return height
}
}
@@ -0,0 +1,368 @@
import Foundation
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import TemporaryCachedPeerDataManager
enum PeerInfoMemberRole {
case creator
case admin
case member
}
enum PeerInfoMember: Equatable {
case channelMember(participant: RenderedChannelParticipant, storyStats: PeerStoryStats?)
case legacyGroupMember(peer: RenderedPeer, role: PeerInfoMemberRole, invitedBy: PeerId?, presence: TelegramUserPresence?, storyStats: PeerStoryStats?)
case account(peer: RenderedPeer)
var id: PeerId {
switch self {
case let .channelMember(participant, _):
return participant.peer.id
case let .legacyGroupMember(peer, _, _, _, _):
return peer.peerId
case let .account(peer):
return peer.peerId
}
}
var peer: Peer {
switch self {
case let .channelMember(participant, _):
return participant.peer
case let .legacyGroupMember(peer, _, _, _, _):
return peer.peers[peer.peerId]!
case let .account(peer):
return peer.peers[peer.peerId]!
}
}
var presence: TelegramUserPresence? {
switch self {
case let .channelMember(participant, _):
return participant.presences[participant.peer.id] as? TelegramUserPresence
case let .legacyGroupMember(_, _, _, presence, _):
return presence
case .account:
return nil
}
}
var role: PeerInfoMemberRole {
switch self {
case let .channelMember(participant, _):
switch participant.participant {
case .creator:
return .creator
case let .member(_, _, adminInfo, _, _, _):
if adminInfo != nil {
return .admin
} else {
return .member
}
}
case let .legacyGroupMember(_, role, _, _, _):
return role
case .account:
return .member
}
}
var rank: String? {
switch self {
case let .channelMember(participant, _):
switch participant.participant {
case let .creator(_, _, rank):
return rank
case let .member(_, _, _, _, rank, _):
return rank
}
case .legacyGroupMember:
return nil
case .account:
return nil
}
}
var storyStats: PeerStoryStats? {
switch self {
case let .channelMember(_, value):
return value
case let .legacyGroupMember(_, _, _, _, value):
return value
case .account:
return nil
}
}
}
enum PeerInfoMembersDataState: Equatable {
case loading(isInitial: Bool)
case ready(canLoadMore: Bool)
}
struct PeerInfoMembersState: Equatable {
var canAddMembers: Bool
var members: [PeerInfoMember]
var dataState: PeerInfoMembersDataState
}
private func membersSortedByPresence(_ members: [PeerInfoMember], accountPeerId: PeerId) -> [PeerInfoMember] {
return members.sorted(by: { lhs, rhs in
if lhs.id == accountPeerId {
return true
} else if rhs.id == accountPeerId {
return false
}
let lhsPresence = lhs.presence
let rhsPresence = rhs.presence
if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence {
if lhsPresence.status < rhsPresence.status {
return false
} else if lhsPresence.status > rhsPresence.status {
return true
}
} else if let _ = lhsPresence {
return true
} else if let _ = rhsPresence {
return false
}
return lhs.id < rhs.id
})
}
private final class PeerInfoMembersContextImpl {
private let queue: Queue
private let context: AccountContext
private let peerId: PeerId
private var canAddMembers = false
private var members: [PeerInfoMember] = []
private var dataState: PeerInfoMembersDataState = .loading(isInitial: true)
private var removingMemberIds: [PeerId: Disposable] = [:]
private var membersHidden: Bool?
private let stateValue = Promise<PeerInfoMembersState>()
var state: Signal<PeerInfoMembersState, NoError> {
return self.stateValue.get()
}
private let disposable = MetaDisposable()
private let peerDisposable = MetaDisposable()
private var channelMembersControl: PeerChannelMemberCategoryControl?
init(queue: Queue, context: AccountContext, peerId: PeerId) {
self.queue = queue
self.context = context
self.peerId = peerId
self.pushState()
if peerId.namespace == Namespaces.Peer.CloudChannel {
let (disposable, control) = context.peerChannelMemberCategoriesContextsManager.recent(engine: context.engine, postbox: context.account.postbox, network: context.account.network, accountPeerId: context.account.peerId, peerId: peerId, requestUpdate: true, updated: { [weak self] state in
queue.async {
guard let strongSelf = self else {
return
}
let unsortedMembers = state.list.map { item -> PeerInfoMember in
return .channelMember(participant: item, storyStats: state.peerStoryStats[item.peer.id])
}
let members: [PeerInfoMember]
if unsortedMembers.count <= 50 {
members = membersSortedByPresence(unsortedMembers, accountPeerId: strongSelf.context.account.peerId)
} else {
members = unsortedMembers
}
strongSelf.members = members
switch state.loadingState {
case let .loading(initial):
strongSelf.dataState = .loading(isInitial: initial)
case let .ready(hasMore):
strongSelf.dataState = .ready(canLoadMore: hasMore)
}
strongSelf.pushState()
}
})
self.disposable.set(disposable)
self.channelMembersControl = control
self.peerDisposable.set((context.account.postbox.peerView(id: peerId)
|> deliverOn(self.queue)).startStrict(next: { [weak self] view in
guard let strongSelf = self else {
return
}
if let channel = peerViewMainPeer(view) as? TelegramChannel {
var canAddMembers = false
switch channel.info {
case .broadcast:
break
case .group:
if channel.flags.contains(.isCreator) || channel.hasPermission(.inviteMembers) {
canAddMembers = true
}
}
strongSelf.canAddMembers = canAddMembers
strongSelf.pushState()
}
var membersHidden: Bool?
if let cachedData = view.cachedData as? CachedChannelData, case let .known(value) = cachedData.membersHidden {
membersHidden = value.value
}
if strongSelf.membersHidden != membersHidden {
let shouldResetList = strongSelf.membersHidden != nil
strongSelf.membersHidden = membersHidden
if shouldResetList, let control = strongSelf.channelMembersControl {
context.peerChannelMemberCategoriesContextsManager.reset(peerId: peerId, control: control)
}
}
}))
} else if peerId.namespace == Namespaces.Peer.CloudGroup {
self.disposable.set((context.account.postbox.peerView(id: peerId)
|> deliverOn(self.queue)).startStrict(next: { [weak self] view in
guard let strongSelf = self, let cachedData = view.cachedData as? CachedGroupData, let participantsData = cachedData.participants else {
return
}
var unsortedMembers: [PeerInfoMember] = []
for participant in participantsData.participants {
if let peer = view.peers[participant.peerId] {
let role: PeerInfoMemberRole
let invitedBy: PeerId?
switch participant {
case .creator:
role = .creator
invitedBy = nil
case let .admin(_, invitedByValue, _):
role = .admin
invitedBy = invitedByValue
case let .member(_, invitedByValue, _):
role = .member
invitedBy = invitedByValue
}
unsortedMembers.append(.legacyGroupMember(peer: RenderedPeer(peer: peer), role: role, invitedBy: invitedBy, presence: view.peerPresences[participant.peerId] as? TelegramUserPresence, storyStats: view.memberStoryStats[participant.peerId]))
}
}
if let group = peerViewMainPeer(view) as? TelegramGroup {
var canAddMembers = false
switch group.role {
case .admin, .creator:
canAddMembers = true
case .member:
break
}
if !group.hasBannedPermission(.banAddMembers) {
canAddMembers = true
}
strongSelf.canAddMembers = canAddMembers
}
strongSelf.members = membersSortedByPresence(unsortedMembers, accountPeerId: strongSelf.context.account.peerId)
strongSelf.dataState = .ready(canLoadMore: false)
strongSelf.pushState()
}))
} else {
self.dataState = .ready(canLoadMore: false)
self.pushState()
}
}
deinit {
self.disposable.dispose()
self.peerDisposable.dispose()
for (_, disposable) in self.removingMemberIds {
disposable.dispose()
}
}
private func pushState() {
if self.removingMemberIds.isEmpty {
self.stateValue.set(.single(PeerInfoMembersState(canAddMembers: self.canAddMembers, members: self.members, dataState: self.dataState)))
} else {
self.stateValue.set(.single(PeerInfoMembersState(canAddMembers: self.canAddMembers, members: self.members.filter { member in
return self.removingMemberIds[member.id] == nil
}, dataState: self.dataState)))
}
}
func loadMore() {
if case .ready(true) = self.dataState, let channelMembersControl = self.channelMembersControl {
self.context.peerChannelMemberCategoriesContextsManager.loadMore(peerId: self.peerId, control: channelMembersControl)
}
}
func removeMember(memberId: PeerId) {
if self.removingMemberIds[memberId] == nil {
let signal: Signal<Never, NoError>
if self.peerId.namespace == Namespaces.Peer.CloudChannel {
signal = context.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: self.context.engine, peerId: self.peerId, memberId: memberId, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max))
|> ignoreValues
} else {
signal = self.context.engine.peers.removePeerMember(peerId: self.peerId, memberId: memberId)
|> ignoreValues
}
let completed: () -> Void = { [weak self] in
guard let strongSelf = self else {
return
}
if let disposable = strongSelf.removingMemberIds.removeValue(forKey: memberId) {
disposable.dispose()
strongSelf.pushState()
}
}
let disposable = MetaDisposable()
self.removingMemberIds[memberId] = disposable
self.pushState()
disposable.set((signal
|> deliverOn(self.queue)).startStrict(completed: {
completed()
}))
}
}
}
final class PeerInfoMembersContext: Equatable {
private let queue = Queue.mainQueue()
private let impl: QueueLocalObject<PeerInfoMembersContextImpl>
var state: Signal<PeerInfoMembersState, NoError> {
return Signal { subscriber in
let disposable = MetaDisposable()
self.impl.with { impl in
disposable.set(impl.state.start(next: { value in
subscriber.putNext(value)
}))
}
return disposable
}
}
init(context: AccountContext, peerId: PeerId) {
let queue = self.queue
self.impl = QueueLocalObject(queue: queue, generate: {
return PeerInfoMembersContextImpl(queue: queue, context: context, peerId: peerId)
})
}
func loadMore() {
self.impl.with { impl in
impl.loadMore()
}
}
func removeMember(memberId: PeerId) {
self.impl.with { impl in
impl.removeMember(memberId: memberId)
}
}
static func ==(lhs: PeerInfoMembersContext, rhs: PeerInfoMembersContext) -> Bool {
return lhs === rhs
}
}
@@ -0,0 +1,879 @@
import Foundation
import UIKit
import Display
import SSignalKit
import SwiftSignalKit
import TelegramCore
import AccountContext
import MediaEditor
import MediaEditorScreen
import CameraScreen
import Photos
import PeerInfoAvatarListNode
import MapResourceToAvatarSizes
import AvatarEditorScreen
import OverlayStatusController
import UndoUI
import PeerAvatarGalleryUI
import PresentationDataUtils
import LegacyComponents
import LegacyMediaPickerUI
extension PeerInfoScreenImpl {
func openAvatarForEditing(mode: PeerInfoAvatarEditingMode = .generic, fromGallery: Bool = false, completion: @escaping (UIImage?) -> Void = { _ in }, completedWithUploadingImage: @escaping (UIImage, Signal<PeerInfoAvatarUploadStatus, NoError>) -> UIView? = { _, _ in nil }) {
guard !self.presentAccountFrozenInfoIfNeeded() else {
return
}
guard let data = self.controllerNode.data, let peer = data.peer, mode != .generic || canEditPeerInfo(context: self.context, peer: peer, chatLocation: self.chatLocation, threadData: data.threadData) else {
return
}
self.view.endEditing(true)
let peerId = self.peerId
var isForum = false
if let peer = peer as? TelegramChannel, peer.isForumOrMonoForum {
isForum = true
}
var currentIsVideo = false
var emojiMarkup: TelegramMediaImage.EmojiMarkup?
let item = self.controllerNode.headerNode.avatarListNode.listContainerNode.currentItemNode?.item
if let item = item, case let .image(_, _, videoRepresentations, _, _, emojiMarkupValue) = item {
currentIsVideo = !videoRepresentations.isEmpty
emojiMarkup = emojiMarkupValue
}
let _ = isForum
let _ = currentIsVideo
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
guard let self, let peer else {
return
}
let keyboardInputData = Promise<AvatarKeyboardInputData>()
keyboardInputData.set(AvatarEditorScreen.inputData(context: self.context, isGroup: peer.id.namespace != Namespaces.Peer.CloudUser))
var hasPhotos = false
if !peer.profileImageRepresentations.isEmpty {
hasPhotos = true
}
var hasDeleteButton = false
if case .generic = mode {
hasDeleteButton = hasPhotos && !fromGallery
} else if case .custom = mode {
hasDeleteButton = peer.profileImageRepresentations.first?.isPersonal == true
} else if case .fallback = mode {
if let cachedData = data.cachedData as? CachedUserData, case let .known(photo) = cachedData.fallbackPhoto {
hasDeleteButton = photo != nil
}
}
struct ConfirmationAlert {
let title: String
let photoText: String
let videoText: String
let action: String
}
let confirmationAlert: ConfirmationAlert?
switch mode {
case .suggest:
confirmationAlert = ConfirmationAlert(
title: self.presentationData.strings.UserInfo_SuggestPhotoTitle(peer.compactDisplayTitle).string,
photoText: self.presentationData.strings.UserInfo_SuggestPhoto_AlertPhotoText(peer.compactDisplayTitle).string,
videoText: self.presentationData.strings.UserInfo_SuggestPhoto_AlertVideoText(peer.compactDisplayTitle).string,
action: self.presentationData.strings.UserInfo_SuggestPhoto_AlertSuggest
)
case .custom:
confirmationAlert = ConfirmationAlert(
title: self.presentationData.strings.UserInfo_SetCustomPhotoTitle(peer.compactDisplayTitle).string,
photoText: self.presentationData.strings.UserInfo_SetCustomPhoto_AlertPhotoText(peer.compactDisplayTitle, peer.compactDisplayTitle).string,
videoText: self.presentationData.strings.UserInfo_SetCustomPhoto_AlertVideoText(peer.compactDisplayTitle, peer.compactDisplayTitle).string,
action: self.presentationData.strings.UserInfo_SetCustomPhoto_AlertSet
)
default:
confirmationAlert = nil
}
let parentController = (self.context.sharedContext.mainWindow?.viewController as? NavigationController)?.topViewController as? ViewController
var dismissImpl: (() -> Void)?
let mainController = self.context.sharedContext.makeAvatarMediaPickerScreen(context: self.context, getSourceRect: { return nil }, canDelete: hasDeleteButton, performDelete: { [weak self] in
self?.openAvatarRemoval(mode: mode, peer: peer, item: item)
}, completion: { result, transitionView, transitionRect, transitionImage, fromCamera, transitionOut, cancelled in
var resultImage: UIImage?
let uploadStatusPromise = Promise<PeerInfoAvatarUploadStatus>(.progress(0.0))
let subject: Signal<MediaEditorScreenImpl.Subject?, NoError>
if let asset = result as? PHAsset {
subject = .single(.asset(asset))
} else if let image = result as? UIImage {
subject = .single(.image(image: image, dimensions: PixelDimensions(image.size), additionalImage: nil, additionalImagePosition: .bottomRight, fromCamera: false))
} else if let result = result as? Signal<CameraScreenImpl.Result, NoError> {
subject = result
|> map { value -> MediaEditorScreenImpl.Subject? in
switch value {
case .pendingImage:
return nil
case let .image(image):
return .image(image: image.image, dimensions: PixelDimensions(image.image.size), additionalImage: nil, additionalImagePosition: .topLeft, fromCamera: false)
case let .video(video):
return .video(videoPath: video.videoPath, thumbnail: video.coverImage, mirror: video.mirror, additionalVideoPath: nil, additionalThumbnail: nil, dimensions: video.dimensions, duration: video.duration, videoPositionChanges: [], additionalVideoPosition: .topLeft, fromCamera: false)
default:
return nil
}
}
} else {
let peerType: AvatarEditorScreen.PeerType
if mode == .suggest {
peerType = .suggest
} else if case .legacyGroup = peer {
peerType = .group
} else if case let .channel(channel) = peer {
if case .group = channel.info {
peerType = channel.isForumOrMonoForum ? .forum : .group
} else {
peerType = .channel
}
} else {
peerType = .user
}
let controller = AvatarEditorScreen(context: self.context, inputData: keyboardInputData.get(), peerType: peerType, markup: emojiMarkup)
controller.imageCompletion = { [weak self] image, commit in
resultImage = image
self?.updateProfilePhoto(image, mode: mode, uploadStatus: uploadStatusPromise)
commit()
}
controller.videoCompletion = { [weak self] image, url, values, markup, commit in
resultImage = image
self?.updateProfileVideo(image, video: nil, values: nil, markup: markup, mode: mode, uploadStatus: uploadStatusPromise)
commit()
}
parentController?.push(controller)
//isFromEditor = true
return
}
let editorController = MediaEditorScreenImpl(
context: self.context,
mode: .avatarEditor,
subject: subject,
transitionIn: fromCamera ? .camera : transitionView.flatMap({ .gallery(
MediaEditorScreenImpl.TransitionIn.GalleryTransitionIn(
sourceView: $0,
sourceRect: transitionRect,
sourceImage: transitionImage
)
) }),
transitionOut: { finished, isNew in
if !finished {
if let transitionView {
return MediaEditorScreenImpl.TransitionOut(
destinationView: transitionView,
destinationRect: transitionView.bounds,
destinationCornerRadius: 0.0
)
}
} else if let resultImage, let transitionOutView = completedWithUploadingImage(resultImage, uploadStatusPromise.get()) {
transitionOutView.isHidden = true
return MediaEditorScreenImpl.TransitionOut(
destinationView: transitionOutView,
destinationRect: transitionOutView.bounds,
destinationCornerRadius: transitionOutView.bounds.height * 0.5,
completion: { [weak transitionOutView] in
transitionOutView?.isHidden = false
}
)
}
return nil
},
willComplete: { [weak self, weak parentController] image, isVideo, commit in
if let self, let confirmationAlert, let image {
let controller = photoUpdateConfirmationController(context: self.context, peer: peer, image: image, text: isVideo ? confirmationAlert.videoText : confirmationAlert.photoText, doneTitle: confirmationAlert.action, commit: {
commit()
})
parentController?.presentInGlobalOverlay(controller)
} else {
commit()
}
},
completion: { [weak self] results, commit in
guard let result = results.first else {
return
}
switch result.media {
case let .image(image, _):
resultImage = image
self?.updateProfilePhoto(image, mode: mode, uploadStatus: uploadStatusPromise)
commit({})
case let .video(video, coverImage, values, _, _):
if let coverImage {
resultImage = coverImage
self?.updateProfileVideo(coverImage, video: video, values: values, markup: nil, mode: mode, uploadStatus: uploadStatusPromise)
}
commit({})
default:
break
}
dismissImpl?()
} as ([MediaEditorScreenImpl.Result], @escaping (@escaping () -> Void) -> Void) -> Void
)
editorController.cancelled = { _ in
cancelled()
}
if self.navigationController != nil {
self.push(editorController)
} else {
self.parentController?.pushViewController(editorController)
}
}, dismissed: {
})
dismissImpl = { [weak self, weak mainController] in
if let mainController, let navigationController = mainController.navigationController {
var viewControllers = navigationController.viewControllers
viewControllers = viewControllers.filter { c in
return !(c is CameraScreen) && c !== mainController
}
navigationController.setViewControllers(viewControllers, animated: false)
}
if let self, let navigationController = self.parentController, let mainController {
var viewControllers = navigationController.viewControllers
viewControllers = viewControllers.filter { c in
return !(c is CameraScreen) && c !== mainController
}
navigationController.setViewControllers(viewControllers, animated: false)
}
}
mainController.navigationPresentation = .flatModal
mainController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
if self.navigationController != nil {
self.push(mainController)
} else {
self.parentController?.pushViewController(mainController)
}
})
}
func openAvatarRemoval(mode: PeerInfoAvatarEditingMode, peer: EnginePeer? = nil, item: PeerInfoAvatarListItem? = nil, completion: @escaping () -> Void = {}) {
let proceed = { [weak self] in
guard let strongSelf = self else {
return
}
completion()
if let item = item {
strongSelf.controllerNode.deleteProfilePhoto(item)
}
if mode != .fallback {
if let peer = peer, let _ = peer.smallProfileImage {
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil)
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
}
}
let postbox = strongSelf.context.account.postbox
let signal: Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError>
if case .custom = mode {
signal = strongSelf.context.engine.contacts.updateContactPhoto(peerId: strongSelf.peerId, resource: nil, videoResource: nil, videoStartTimestamp: nil, markup: nil, mode: .custom, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
} else if case .fallback = mode {
signal = strongSelf.context.engine.accountData.removeFallbackPhoto(reference: nil)
|> castError(UploadPeerPhotoError.self)
|> map { _ in
return .complete([])
}
} else {
signal = strongSelf.context.engine.peers.updatePeerPhoto(peerId: strongSelf.peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
}
strongSelf.controllerNode.updateAvatarDisposable.set((signal
|> deliverOnMainQueue).startStrict(next: { result in
guard let strongSelf = self else {
return
}
switch result {
case .complete:
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil)
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
case .progress:
break
}
}))
}
let presentationData = self.presentationData
let actionSheet = ActionSheetController(presentationData: presentationData)
let items: [ActionSheetItem] = [
ActionSheetButtonItem(title: presentationData.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
proceed()
})
]
actionSheet.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
(self.navigationController?.topViewController as? ViewController)?.present(actionSheet, in: .window(.root))
}
private func setupProfilePhotoUpload(image: UIImage, mode: PeerInfoAvatarEditingMode, indefiniteProgress: Bool) -> LocalFileMediaResource? {
guard let data = image.jpegData(compressionQuality: 0.6) else {
return nil
}
if self.controllerNode.headerNode.isAvatarExpanded {
self.controllerNode.headerNode.ignoreCollapse = true
self.controllerNode.headerNode.updateIsAvatarExpanded(false, transition: .immediate)
self.controllerNode.updateNavigationExpansionPresentation(isExpanded: false, animated: true)
}
self.controllerNode.scrollNode.view.setContentOffset(CGPoint(), animated: false)
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
self.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data)
let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: mode == .custom)
if [.suggest, .fallback].contains(mode) {
} else {
if indefiniteProgress {
self.controllerNode.state = self.controllerNode.state.withAvatarUploadProgress(.indefinite)
}
self.controllerNode.state = self.controllerNode.state.withUpdatingAvatar(.image(representation))
}
if let (layout, navigationHeight) = self.controllerNode.validLayout {
self.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: mode == .custom ? .animated(duration: 0.2, curve: .easeInOut) : .immediate, additive: false)
}
self.controllerNode.headerNode.ignoreCollapse = false
return resource
}
public func updateProfilePhoto(_ image: UIImage, mode: PeerInfoAvatarEditingMode, uploadStatus: Promise<PeerInfoAvatarUploadStatus>?) {
guard let resource = setupProfilePhotoUpload(image: image, mode: mode, indefiniteProgress: false) else {
uploadStatus?.set(.single(.done))
return
}
let postbox = self.context.account.postbox
let signal: Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError>
if self.isSettings || self.isMyProfile {
if case .fallback = mode {
signal = self.context.engine.accountData.updateFallbackPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
} else {
signal = self.context.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
}
} else if case .custom = mode {
signal = self.context.engine.contacts.updateContactPhoto(peerId: self.peerId, resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mode: .custom, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
} else if case .suggest = mode {
signal = self.context.engine.contacts.updateContactPhoto(peerId: self.peerId, resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mode: .suggest, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
} else {
signal = self.context.engine.peers.updatePeerPhoto(peerId: self.peerId, photo: self.context.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
}
var dismissStatus: (() -> Void)?
if [.suggest, .fallback, .accept].contains(mode) {
let statusController = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { [weak self] in
self?.controllerNode.updateAvatarDisposable.set(nil)
dismissStatus?()
}))
dismissStatus = { [weak statusController] in
statusController?.dismiss()
}
if let topController = self.navigationController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else if let topController = self.parentController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else {
self.presentInGlobalOverlay(statusController)
}
}
self.controllerNode.updateAvatarDisposable.set((signal
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let strongSelf = self else {
return
}
switch result {
case .complete:
uploadStatus?.set(.single(.done))
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil).withAvatarUploadProgress(nil)
case let .progress(value):
uploadStatus?.set(.single(.progress(value)))
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(value)))
}
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
if case .complete = result {
dismissStatus?()
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
if let strongSelf = self, let peer {
switch mode {
case .fallback:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: nil, text: strongSelf.presentationData.strings.Privacy_ProfilePhoto_PublicPhotoSuccess, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
case .custom:
strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.UserInfo_SetCustomPhoto_SuccessPhotoText(peer.compactDisplayTitle).string, action: nil, duration: 5), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
let _ = (strongSelf.context.peerChannelMemberCategoriesContextsManager.profilePhotos(postbox: strongSelf.context.account.postbox, network: strongSelf.context.account.network, peerId: strongSelf.peerId, fetch: peerInfoProfilePhotos(context: strongSelf.context, peerId: strongSelf.peerId)) |> ignoreValues).startStandalone()
case .suggest:
if let navigationController = (strongSelf.navigationController as? NavigationController) {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .default, completion: { _ in
}))
}
case .accept:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: strongSelf.presentationData.strings.Conversation_SuggestedPhotoSuccess, text: strongSelf.presentationData.strings.Conversation_SuggestedPhotoSuccessText, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] action in
if case .info = action {
self?.parentController?.openSettings()
}
return false
}), in: .current)
default:
break
}
}
})
}
}))
}
public func updateProfileVideo(_ image: UIImage, video: MediaEditorScreenImpl.MediaResult.VideoResult?, values: MediaEditorValues?, markup: UploadPeerPhotoMarkup?, mode: PeerInfoAvatarEditingMode, uploadStatus: Promise<PeerInfoAvatarUploadStatus>?) {
var uploadVideo = true
if let _ = markup {
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let uploadVideoValue = data["upload_markup_video"] as? Bool, uploadVideoValue {
uploadVideo = true
} else {
uploadVideo = false
}
}
guard let photoResource = self.setupProfilePhotoUpload(image: image, mode: mode, indefiniteProgress: !uploadVideo) else {
uploadStatus?.set(.single(.done))
return
}
var videoStartTimestamp: Double? = nil
if let values, let coverImageTimestamp = values.coverImageTimestamp, coverImageTimestamp > 0.0 {
videoStartTimestamp = coverImageTimestamp - (values.videoTrimRange?.lowerBound ?? 0.0)
}
let account = self.context.account
let context = self.context
let videoResource: Signal<TelegramMediaResource?, UploadPeerPhotoError>
if uploadVideo, let video, let values {
var exportSubject: Signal<(MediaEditorVideoExport.Subject, Double), NoError>?
switch video {
case let .imageFile(path):
if let image = UIImage(contentsOfFile: path) {
exportSubject = .single((.image(image: image), 3.0))
}
case let .videoFile(path):
let asset = AVURLAsset(url: NSURL(fileURLWithPath: path) as URL)
exportSubject = .single((.video(asset: asset, isStory: false), asset.duration.seconds))
case let .asset(localIdentifier):
exportSubject = Signal { subscriber in
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
if fetchResult.count != 0 {
let asset = fetchResult.object(at: 0)
if asset.mediaType == .video {
PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in
if let avAsset {
subscriber.putNext((.video(asset: avAsset, isStory: true), avAsset.duration.seconds))
subscriber.putCompletion()
}
}
} else {
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in
if let image {
subscriber.putNext((.image(image: image), 3.0))
subscriber.putCompletion()
}
}
}
}
return EmptyDisposable
}
}
if let exportSubject {
videoResource = exportSubject
|> castError(UploadPeerPhotoError.self)
|> mapToSignal { exportSubject, duration in
return Signal<TelegramMediaResource?, UploadPeerPhotoError> { subscriber in
let configuration = recommendedVideoExportConfiguration(values: values, duration: duration, forceFullHd: true, frameRate: 60.0, isAvatar: true)
let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4")
let videoExport = MediaEditorVideoExport(postbox: context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: tempFile.path, textScale: 2.0)
let _ = (videoExport.status
|> deliverOnMainQueue).startStandalone(next: { [weak self] status in
guard let self else {
return
}
switch status {
case .completed:
if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path), options: .mappedIfSafe) {
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true)
subscriber.putNext(resource)
subscriber.putCompletion()
}
EngineTempBox.shared.dispose(tempFile)
case let .progress(progress):
Queue.mainQueue().async {
self.controllerNode.state = self.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(progress * 0.45)))
self.requestLayout(transition: .immediate)
}
default:
break
}
})
return EmptyDisposable
}
}
} else {
videoResource = .single(nil)
}
} else {
videoResource = .single(nil)
}
var dismissStatus: (() -> Void)?
if [.suggest, .fallback, .accept].contains(mode) {
let statusController = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { [weak self] in
self?.controllerNode.updateAvatarDisposable.set(nil)
dismissStatus?()
}))
dismissStatus = { [weak statusController] in
statusController?.dismiss()
}
if let topController = self.navigationController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else if let topController = self.parentController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else {
self.presentInGlobalOverlay(statusController)
}
}
let peerId = self.peerId
let isSettings = self.isSettings
let isMyProfile = self.isMyProfile
self.controllerNode.updateAvatarDisposable.set((videoResource
|> mapToSignal { videoResource -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> in
if isSettings || isMyProfile {
if case .fallback = mode {
return context.engine.accountData.updateFallbackPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else {
return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
}
} else if case .custom = mode {
return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .custom, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else if case .suggest = mode {
return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .suggest, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else {
return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: videoResource.flatMap { context.engine.peers.uploadedPeerVideo(resource: $0) |> map(Optional.init) }, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let strongSelf = self else {
return
}
switch result {
case .complete:
uploadStatus?.set(.single(.done))
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil).withAvatarUploadProgress(nil)
case let .progress(value):
uploadStatus?.set(.single(.progress(value)))
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(0.45 + value * 0.55)))
}
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
if case .complete = result {
dismissStatus?()
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
if let strongSelf = self, let peer {
switch mode {
case .fallback:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: nil, text: strongSelf.presentationData.strings.Privacy_ProfilePhoto_PublicVideoSuccess, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
case .custom:
strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.UserInfo_SetCustomPhoto_SuccessVideoText(peer.compactDisplayTitle).string, action: nil, duration: 5), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
let _ = (strongSelf.context.peerChannelMemberCategoriesContextsManager.profilePhotos(postbox: strongSelf.context.account.postbox, network: strongSelf.context.account.network, peerId: strongSelf.peerId, fetch: peerInfoProfilePhotos(context: strongSelf.context, peerId: strongSelf.peerId)) |> ignoreValues).startStandalone()
case .suggest:
if let navigationController = (strongSelf.navigationController as? NavigationController) {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .default, completion: { _ in
}))
}
case .accept:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccess, text: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccessText, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] action in
if case .info = action {
self?.parentController?.openSettings()
}
return false
}), in: .current)
default:
break
}
}
})
}
}))
}
public func oldUpdateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?, mode: PeerInfoAvatarEditingMode) {
var markup: UploadPeerPhotoMarkup? = nil
if let fileId = adjustments?.documentId, let backgroundColors = adjustments?.colors as? [Int32], fileId != 0 {
if let packId = adjustments?.stickerPackId, let accessHash = adjustments?.stickerPackAccessHash, packId != 0 {
markup = .sticker(packReference: .id(id: packId, accessHash: accessHash), fileId: fileId, backgroundColors: backgroundColors)
} else {
markup = .emoji(fileId: fileId, backgroundColors: backgroundColors)
}
}
var uploadVideo = true
if let _ = markup {
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let uploadVideoValue = data["upload_markup_video"] as? Bool, uploadVideoValue {
uploadVideo = true
} else {
uploadVideo = false
}
}
guard let photoResource = self.setupProfilePhotoUpload(image: image, mode: mode, indefiniteProgress: !uploadVideo) else {
return
}
var videoStartTimestamp: Double? = nil
if let adjustments = adjustments, adjustments.videoStartValue > 0.0 {
videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue
}
let account = self.context.account
let context = self.context
let videoResource: Signal<TelegramMediaResource?, UploadPeerPhotoError>
if uploadVideo {
videoResource = Signal<TelegramMediaResource?, UploadPeerPhotoError> { [weak self] subscriber in
let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in
if let paintingData = adjustments.paintingData, paintingData.hasAnimation {
return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments)
} else {
return nil
}
}
let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4")
let uploadInterface = LegacyLiveUploadInterface(context: context)
let signal: SSignal
if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer {
let durationSignal: SSignal = SSignal(generator: { subscriber in
let disposable = (entityRenderer.duration()).start(next: { duration in
subscriber.putNext(duration)
subscriber.putCompletion()
})
return SBlockDisposable(block: {
disposable.dispose()
})
})
signal = durationSignal.map(toSignal: { duration -> SSignal in
if let duration = duration as? Double {
return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)!
} else {
return SSignal.single(nil)
}
})
} else if let asset = asset as? AVAsset {
signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)!
} else {
signal = SSignal.complete()
}
let signalDisposable = signal.start(next: { next in
if let result = next as? TGMediaVideoConversionResult {
if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) {
account.postbox.mediaBox.storeResourceData(photoResource.id, data: data)
}
if let timestamp = videoStartTimestamp {
videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05))
}
var value = stat()
if stat(result.fileURL.path, &value) == 0 {
if let data = try? Data(contentsOf: result.fileURL) {
let resource: TelegramMediaResource
if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult {
resource = LocalFileMediaResource(fileId: liveUploadData.id)
} else {
resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
}
account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true)
subscriber.putNext(resource)
EngineTempBox.shared.dispose(tempFile)
}
}
subscriber.putCompletion()
} else if let strongSelf = self, let progress = next as? NSNumber {
Queue.mainQueue().async {
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(progress.floatValue * 0.45)))
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
}
}
}, error: { _ in
}, completed: nil)
let disposable = ActionDisposable {
signalDisposable?.dispose()
}
return ActionDisposable {
disposable.dispose()
}
}
} else {
videoResource = .single(nil)
}
var dismissStatus: (() -> Void)?
if [.suggest, .fallback, .accept].contains(mode) {
let statusController = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { [weak self] in
self?.controllerNode.updateAvatarDisposable.set(nil)
dismissStatus?()
}))
dismissStatus = { [weak statusController] in
statusController?.dismiss()
}
if let topController = self.navigationController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else if let topController = self.parentController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else {
self.presentInGlobalOverlay(statusController)
}
}
let peerId = self.peerId
let isSettings = self.isSettings
let isMyProfile = self.isMyProfile
self.controllerNode.updateAvatarDisposable.set((videoResource
|> mapToSignal { videoResource -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> in
if isSettings || isMyProfile {
if case .fallback = mode {
return context.engine.accountData.updateFallbackPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else {
return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
}
} else if case .custom = mode {
return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .custom, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else if case .suggest = mode {
return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .suggest, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else {
return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: videoResource.flatMap { context.engine.peers.uploadedPeerVideo(resource: $0) |> map(Optional.init) }, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let strongSelf = self else {
return
}
switch result {
case .complete:
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil).withAvatarUploadProgress(nil)
case let .progress(value):
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(0.45 + value * 0.55)))
}
if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout {
strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false)
}
if case .complete = result {
dismissStatus?()
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak self] peer in
if let strongSelf = self, let peer {
switch mode {
case .fallback:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: nil, text: strongSelf.presentationData.strings.Privacy_ProfilePhoto_PublicVideoSuccess, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
case .custom:
strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.UserInfo_SetCustomPhoto_SuccessVideoText(peer.compactDisplayTitle).string, action: nil, duration: 5), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
let _ = (strongSelf.context.peerChannelMemberCategoriesContextsManager.profilePhotos(postbox: strongSelf.context.account.postbox, network: strongSelf.context.account.network, peerId: strongSelf.peerId, fetch: peerInfoProfilePhotos(context: strongSelf.context, peerId: strongSelf.peerId)) |> ignoreValues).startStandalone()
case .suggest:
if let navigationController = (strongSelf.navigationController as? NavigationController) {
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .default, completion: { _ in
}))
}
case .accept:
(strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccess, text: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccessText, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] action in
if case .info = action {
self?.parentController?.openSettings()
}
return false
}), in: .current)
default:
break
}
}
})
}
}))
}
}
@@ -0,0 +1,27 @@
import Foundation
import UIKit
import SwiftSignalKit
import Postbox
import TelegramCore
import AsyncDisplayKit
import Display
import AccountContext
extension PeerInfoScreenImpl {
func presentAccountFrozenInfoIfNeeded(delay: Bool = false) -> Bool {
if self.context.isFrozen {
let present = {
self.push(self.context.sharedContext.makeAccountFreezeInfoScreen(context: self.context))
}
if delay {
Queue.mainQueue().after(0.3) {
present()
}
} else {
present()
}
return true
}
return false
}
}
@@ -0,0 +1,137 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListUI
import AccountContext
final class PeerInfoScreenMultilineInputItem: PeerInfoScreenItem {
let id: AnyHashable
let text: String
let placeholder: String
let textUpdated: (String) -> Void
let action: () -> Void
let maxLength: Int?
init(
id: AnyHashable,
text: String,
placeholder: String,
textUpdated: @escaping (String) -> Void,
action: @escaping () -> Void,
maxLength: Int?
) {
self.id = id
self.text = text
self.placeholder = placeholder
self.textUpdated = textUpdated
self.action = action
self.maxLength = maxLength
}
func node() -> PeerInfoScreenItemNode {
return PeerInfoScreenMultilineInputItemNode()
}
}
final class PeerInfoScreenMultilineInputItemNode: PeerInfoScreenItemNode {
private let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private(set) var item: PeerInfoScreenMultilineInputItem?
private var itemNode: ItemListMultilineInputItemNode?
override init() {
self.bottomSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
self.clipsToBounds = true
self.addSubnode(self.bottomSeparatorNode)
}
override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat {
guard let item = item as? PeerInfoScreenMultilineInputItem else {
return 10.0
}
self.item = item
let sideInset: CGFloat = 16.0 + safeInsets.left
self.bottomSeparatorNode.backgroundColor = presentationData.theme.list.itemBlocksSeparatorColor
let inputItem = ItemListMultilineInputItem(presentationData: ItemListPresentationData(presentationData), systemStyle: .glass, text: item.text, placeholder: item.placeholder, maxLength: item.maxLength.flatMap { ItemListMultilineInputItemTextLimit(value: $0, display: true) }, sectionId: 0, style: .blocks, returnKeyType: .done, textUpdated: { updatedText in
item.textUpdated(updatedText)
}, action: {
item.action()
}, noInsets: true)
let params = ListViewItemLayoutParams(width: width, leftInset: safeInsets.left, rightInset: safeInsets.right, availableHeight: 1000.0)
let itemNode: ItemListMultilineInputItemNode
if let current = self.itemNode {
itemNode = current
inputItem.updateNode(async: { $0() }, node: {
return itemNode
}, params: params, previousItem: nil, nextItem: nil, animation: .None, completion: { (layout, apply) in
let nodeFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: layout.size.height))
itemNode.contentSize = layout.contentSize
itemNode.insets = layout.insets
itemNode.frame = nodeFrame
apply(ListViewItemApply(isOnScreen: true))
})
} else {
var itemNodeValue: ListViewItemNode?
inputItem.nodeConfiguredForParams(async: { $0() }, params: params, synchronousLoads: false, previousItem: nil, nextItem: nil, completion: { node, apply in
itemNodeValue = node
apply().1(ListViewItemApply(isOnScreen: true))
})
itemNode = itemNodeValue as! ItemListMultilineInputItemNode
self.itemNode = itemNode
self.addSubnode(itemNode)
}
let height = itemNode.contentSize.height
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(), size: itemNode.bounds.size))
var separatorInset: CGFloat = sideInset
if bottomItem != nil {
separatorInset += 49.0
}
let hasCorners = hasCorners && (topItem == nil || bottomItem == nil)
let hasTopCorners = hasCorners && topItem == nil
let hasBottomCorners = hasCorners && bottomItem == nil
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
transition.updateFrame(node: self.maskNode, frame: CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height)))
self.bottomSeparatorNode.isHidden = hasBottomCorners
if self.maskNode.supernode == nil {
self.addSubnode(self.maskNode)
}
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel)))
transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0)
return height
}
func animateErrorIfNeeded() {
self.itemNode?.animateErrorIfNeeded()
}
func focus() {
self.itemNode?.focus()
}
}
@@ -0,0 +1,90 @@
import Foundation
import UIKit
import AsyncDisplayKit
import ContextUI
import AnimationUI
import Display
import TelegramPresentationData
import ComponentFlow
final class PeerInfoSubtitleBadgeView: HighlightTrackingButton {
private let action: () -> Void
private let backgroundView: BlurredBackgroundView
private let labelView = ComponentView<Empty>()
init(action: @escaping () -> Void) {
self.action = action
self.backgroundView = BlurredBackgroundView(color: nil, enableBlur: true)
self.backgroundView.isUserInteractionEnabled = false
super.init(frame: CGRect())
self.addSubview(self.backgroundView)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.highligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "opacity")
self.layer.removeAnimation(forKey: "sublayerTransform")
self.alpha = 0.7
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
transition.updateTransformScale(layer: self.layer, scale: topScale)
} else {
self.alpha = 1.0
self.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformScale(layer: self.layer, scale: 1.0)
self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in
guard let self else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
self.action()
}
func update(title: String, fillColor: UIColor, foregroundColor: UIColor) -> CGSize {
let labelSize = self.labelView.update(
transition: .immediate,
component: AnyComponent(Text(text: title, font: Font.regular(11.0), color: foregroundColor)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let size = CGSize(width: labelSize.width + 7.0 * 2.0, height: labelSize.height + 4.0 * 2.0)
self.backgroundView.frame = CGRect(origin: CGPoint(), size: size)
self.backgroundView.updateColor(color: fillColor, transition: .immediate)
self.backgroundView.update(size: size, cornerRadius: size.height * 0.5, transition: .immediate)
if let labelComponentView = self.labelView.view {
if labelComponentView.superview == nil {
labelComponentView.isUserInteractionEnabled = false
self.addSubview(labelComponentView)
}
labelComponentView.frame = CGRect(origin: CGPoint(x: floor((size.width - labelSize.width) * 0.5), y: floor((size.height - labelSize.height) * 0.5)), size: labelSize)
}
return size
}
}
@@ -0,0 +1,247 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
private final class PhotoUpdateConfirmationAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let text: String
private let textNode: ASTextNode
private let avatarNode: AvatarNode
private let arrowNode: ASImageNode
private let iconNode: ASImageNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private var validLayout: CGSize?
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, peer: EnginePeer, image: UIImage, text: String, actions: [TextAlertAction]) {
self.strings = strings
self.text = text
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
self.arrowNode = ASImageNode()
self.arrowNode.displaysAsynchronously = false
self.arrowNode.displayWithoutProcessing = true
self.iconNode = ASImageNode()
self.iconNode.clipsToBounds = true
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.image = image
self.iconNode.cornerRadius = 30.0
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.avatarNode)
self.addSubnode(self.arrowNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.updateTheme(theme)
self.avatarNode.setPeer(context: context, theme: ptheme, peer: peer)
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.textNode.attributedText = NSAttributedString(string: self.text, font: Font.regular(13.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.arrowNode.image = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/AlertArrow"), color: theme.secondaryColor)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let avatarSize = CGSize(width: 60.0, height: 60.0)
self.avatarNode.updateSize(size: avatarSize)
let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - avatarSize.width) / 2.0) - 44.0, y: origin.y), size: avatarSize)
transition.updateFrame(node: self.avatarNode, frame: avatarFrame)
if let arrowImage = self.arrowNode.image {
let arrowFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - arrowImage.size.width) / 2.0), y: origin.y + floorToScreenPixels((avatarSize.height - arrowImage.size.height) / 2.0)), size: arrowImage.size)
transition.updateFrame(node: self.arrowNode, frame: arrowFrame)
}
if let _ = self.iconNode.image {
let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - avatarSize.width) / 2.0) + 44.0, y: origin.y), size: avatarSize)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
origin.y += avatarSize.height + 10.0
}
let textSize = self.textNode.measure(CGSize(width: size.width - 32.0, height: size.height))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let contentWidth = max(size.width, minActionsWidth)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultSize = CGSize(width: contentWidth, height: avatarSize.height + textSize.height + actionsHeight + 16.0 + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
}
func photoUpdateConfirmationController(context: AccountContext, peer: EnginePeer, image: UIImage, text: String, doneTitle: String, isDark: Bool = true, commit: @escaping () -> Void) -> AlertController {
let theme = defaultDarkColorPresentationTheme
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
if isDark {
presentationData = presentationData.withUpdated(theme: theme)
}
let strings = presentationData.strings
var dismissImpl: ((Bool) -> Void)?
var contentNode: PhotoUpdateConfirmationAlertContentNode?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: doneTitle, action: {
dismissImpl?(true)
commit()
})]
contentNode = PhotoUpdateConfirmationAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, peer: peer, image: image, text: text, actions: actions)
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode!)
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,38 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoStoryGridScreen",
module_name = "PeerInfoStoryGridScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/ComponentFlow",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/ViewControllerComponent",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode",
"//submodules/TelegramUI/Components/ChatListHeaderComponent",
"//submodules/TelegramUI/Components/ChatTitleView",
"//submodules/ContextUI",
"//submodules/UndoUI",
"//submodules/TelegramUI/Components/BottomButtonPanelComponent",
"//submodules/TelegramUI/Components/MoreHeaderButton",
"//submodules/TelegramUI/Components/MediaEditorScreen",
"//submodules/SaveToCameraRoll",
"//submodules/ShareController",
"//submodules/OpenInExternalAppUI",
"//submodules/TelegramUI/Components/SaveProgressScreen",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,766 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import ComponentFlow
import TelegramCore
import PeerInfoVisualMediaPaneNode
import ViewControllerComponent
import ChatListHeaderComponent
import ContextUI
import ChatTitleView
import BottomButtonPanelComponent
import UndoUI
import MoreHeaderButton
import SaveProgressScreen
import SaveToCameraRoll
final class PeerInfoStoryGridScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let peerId: EnginePeer.Id
let scope: PeerInfoStoryGridScreen.Scope
let excludeIds: [Int32]
let selectionModeCompletion: (([EngineStoryItem]) -> Void)?
init(
context: AccountContext,
peerId: EnginePeer.Id,
scope: PeerInfoStoryGridScreen.Scope,
excludeIds: [Int32],
selectionModeCompletion: (([EngineStoryItem]) -> Void)?
) {
self.context = context
self.peerId = peerId
self.scope = scope
self.excludeIds = excludeIds
self.selectionModeCompletion = selectionModeCompletion
}
static func ==(lhs: PeerInfoStoryGridScreenComponent, rhs: PeerInfoStoryGridScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.scope != rhs.scope {
return false
}
if lhs.excludeIds != rhs.excludeIds {
return false
}
return true
}
final class View: UIView {
private var component: PeerInfoStoryGridScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private(set) var paneNode: PeerInfoStoryPaneNode?
private var paneStatusDisposable: Disposable?
private(set) var paneStatusText: String?
private(set) var selectedCount: Int = 0
private var selectionStateDisposable: Disposable?
private var selectionPanel: ComponentView<Empty>?
private weak var mediaGalleryContextMenu: ContextController?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.paneStatusDisposable?.dispose()
self.selectionStateDisposable?.dispose()
}
func morePressed(source: ContextReferenceContentNode) {
guard let component = self.component, let controller = self.environment?.controller(), let pane = self.paneNode else {
return
}
var items: [ContextMenuItem] = []
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
if self.selectedCount != 0 {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.StoryList_ContextSaveToGallery, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self else {
return
}
self.saveSelected()
})))
items.append(.action(ContextMenuActionItem(text: strings.Common_Delete, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let component = self.component, let environment = self.environment else {
return
}
guard let paneNode = self.paneNode, !paneNode.selectedIds.isEmpty else {
return
}
let _ = component.context.engine.messages.deleteStories(peerId: component.peerId, ids: Array(paneNode.selectedIds)).start()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let text: String = presentationData.strings.StoryList_TooltipStoriesDeleted(Int32(paneNode.selectedIds.count))
environment.controller()?.present(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: text, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
paneNode.clearSelection()
})))
} else if let paneNode = self.paneNode {
if !paneNode.isEmpty {
var recurseGenerateAction: ((Bool) -> ContextMenuActionItem)?
let generateAction: (Bool) -> ContextMenuActionItem = { [weak pane] isZoomIn in
let nextZoomLevel = isZoomIn ? pane?.availableZoomLevels().increment : pane?.availableZoomLevels().decrement
let canZoom: Bool = nextZoomLevel != nil
return ContextMenuActionItem(id: isZoomIn ? 0 : 1, text: isZoomIn ? strings.SharedMedia_ZoomIn : strings.SharedMedia_ZoomOut, textColor: canZoom ? .primary : .disabled, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isZoomIn ? "Chat/Context Menu/ZoomIn" : "Chat/Context Menu/ZoomOut"), color: canZoom ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withMultipliedAlpha(0.4))
}, action: canZoom ? { action in
guard let pane = pane, let zoomLevel = isZoomIn ? pane.availableZoomLevels().increment : pane.availableZoomLevels().decrement else {
return
}
pane.updateZoomLevel(level: zoomLevel)
if let recurseGenerateAction = recurseGenerateAction {
action.updateAction(0, recurseGenerateAction(true))
action.updateAction(1, recurseGenerateAction(false))
}
} : nil)
}
recurseGenerateAction = { isZoomIn in
return generateAction(isZoomIn)
}
items.append(.action(generateAction(true)))
items.append(.action(generateAction(false)))
}
if component.peerId == component.context.account.peerId, case .saved = component.scope {
var ignoreNextActions = false
items.append(.action(ContextMenuActionItem(text: presentationData.strings.StoryList_ContextShowArchive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/StoryArchive"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
if ignoreNextActions {
return
}
ignoreNextActions = true
a(.default)
guard let self, let component = self.component else {
return
}
self.environment?.controller()?.push(PeerInfoStoryGridScreen(context: component.context, peerId: component.peerId, scope: .archive))
})))
}
}
if let paneNode = self.paneNode, !paneNode.isSelectionModeActive, case .saved = component.scope {
if !paneNode.isEmpty {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_ContextMenuSelect, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, a in
a(.default)
guard let self, let paneNode = self.paneNode else {
return
}
paneNode.setIsSelectionModeActive(true)
(self.environment?.controller() as? PeerInfoStoryGridScreen)?.updateTitle()
})))
}
}
let contextController = ContextController(presentationData: presentationData, source: .reference(PeerInfoContextReferenceContentSource(controller: controller, sourceNode: source)), items: .single(ContextController.Items(content: .list(items))), gesture: nil)
contextController.passthroughTouchEvent = { [weak self] sourceView, point in
guard let self else {
return .ignore
}
let localPoint = self.convert(sourceView.convert(point, to: nil), from: nil)
guard let localResult = self.hitTest(localPoint, with: nil) else {
return .dismiss(consume: true, result: nil)
}
var testView: UIView? = localResult
while true {
if let testViewValue = testView {
if let node = testViewValue.asyncdisplaykit_node as? PeerInfoStoryPaneNode {
node.brieflyDisableTouchActions()
return .dismiss(consume: false, result: nil)
} else {
testView = testViewValue.superview
}
} else {
break
}
}
return .dismiss(consume: true, result: nil)
}
self.mediaGalleryContextMenu = contextController
controller.presentInGlobalOverlay(contextController)
}
private func saveSelected() {
guard let component = self.component else {
return
}
let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let component = self.component, let peer else {
return
}
guard let peerReference = PeerReference(peer._asPeer()) else {
return
}
guard let paneNode = self.paneNode, !paneNode.selectedIds.isEmpty else {
return
}
var signals: [Signal<Float, NoError>] = []
let sortedItems = paneNode.selectedItems.sorted(by: { lhs, rhs in return lhs.key < rhs.key })
if sortedItems.isEmpty {
return
}
let strings = (component.context.sharedContext.currentPresentationData.with { $0 }).strings
let saveScreen = SaveProgressScreen(context: component.context, content: .progress(strings.Story_TooltipSaving, 0.0))
self.environment?.controller()?.present(saveScreen, in: .current)
let valueNorm: Float = 1.0 / Float(sortedItems.count)
var progressStart: Float = 0.0
for (_, item) in sortedItems {
let itemOffset = progressStart
progressStart += valueNorm
signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia()))
|> map { progress -> Float in
return itemOffset + progress * valueNorm
})
}
var allSignal: Signal<Float, NoError> = .single(0.0)
for signal in signals {
allSignal = allSignal |> then(signal)
}
let disposable = (allSignal
|> deliverOnMainQueue).start(next: { [weak saveScreen] progress in
guard let saveScreen else {
return
}
saveScreen.content = .progress(strings.Story_TooltipSaving, progress)
}, completed: { [weak saveScreen] in
guard let saveScreen else {
return
}
saveScreen.content = .completion(strings.Story_TooltipSaved)
Queue.mainQueue().after(3.0, { [weak saveScreen] in
saveScreen?.dismiss()
})
})
saveScreen.cancelled = {
disposable.dispose()
}
})
}
func scrollToTop() {
guard let paneNode = self.paneNode else {
return
}
let _ = paneNode.scrollToTop()
}
func openCreateStory() {
guard let component = self.component else {
return
}
if let rootController = component.context.sharedContext.mainWindow?.viewController as? TelegramRootControllerInterface {
let coordinator = rootController.openStoryCamera(customTarget: nil, resumeLiveStream: false, transitionIn: nil, transitionedIn: {}, transitionOut: { _, _ in return nil })
coordinator?.animateIn()
}
}
private var isUpdating = false
func update(component: PeerInfoStoryGridScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
self.state = state
let sideInset: CGFloat = 14.0
let environment = environment[EnvironmentType.self].value
let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment
if themeUpdated {
self.backgroundColor = environment.theme.list.plainBackgroundColor
}
var bottomInset: CGFloat = environment.safeInsets.bottom
if self.selectedCount != 0 || (component.scope == .saved && self.paneNode?.isEmpty == false) {
let selectionPanel: ComponentView<Empty>
var selectionPanelTransition = transition
if let current = self.selectionPanel {
selectionPanel = current
} else {
selectionPanelTransition = .immediate
selectionPanel = ComponentView()
self.selectionPanel = selectionPanel
}
let buttonText: String
var buttonIsEnabled = true
if component.selectionModeCompletion != nil {
if self.selectedCount == 0 {
buttonText = environment.strings.Stories_AddStoriesEmpty
buttonIsEnabled = false
} else {
buttonText = environment.strings.Stories_AddStories(Int32(self.selectedCount))
}
} else {
switch component.scope {
case .saved:
buttonText = self.selectedCount > 0 ? environment.strings.ChatList_Context_Archive : environment.strings.StoryList_SavedAddAction
case .archive:
buttonText = environment.strings.StoryList_SaveToProfile
}
}
let selectionPanelSize = selectionPanel.update(
transition: selectionPanelTransition,
component: AnyComponent(BottomButtonPanelComponent(
theme: environment.theme,
title: buttonText,
label: nil,
isEnabled: buttonIsEnabled,
insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: environment.safeInsets.bottom, right: sideInset),
action: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
return
}
guard let paneNode = self.paneNode else {
return
}
if let selectionModeCompletion = component.selectionModeCompletion {
selectionModeCompletion(Array(paneNode.selectedItems.sorted(by: { $0.key < $1.key }).map(\.value)))
environment.controller()?.dismiss()
return
}
let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer else {
return
}
var isGroup = false
if case let .channel(channel) = peer, case .group = channel.info {
isGroup = true
}
switch component.scope {
case .saved:
let selectedCount = paneNode.selectedItems.count
if selectedCount == 0 {
self.openCreateStory()
} else {
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.peerId, ids: paneNode.selectedItems, isPinned: false).start()
paneNode.setIsSelectionModeActive(false)
(self.environment?.controller() as? PeerInfoStoryGridScreen)?.updateTitle()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let title: String = presentationData.strings.StoryList_TooltipStoriesSavedToProfile(Int32(selectedCount))
environment.controller()?.present(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: title, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
}
case .archive:
let _ = component.context.engine.messages.updateStoriesArePinned(peerId: component.peerId, ids: paneNode.selectedItems, isPinned: true).start()
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let title: String
let text: String
if component.peerId == component.context.account.peerId {
title = presentationData.strings.StoryList_TooltipStoriesSavedToProfile(Int32(paneNode.selectedIds.count))
text = presentationData.strings.StoryList_TooltipStoriesSavedToProfileText
} else {
title = isGroup ? presentationData.strings.StoryList_TooltipStoriesSavedToGroup(Int32(paneNode.selectedIds.count)) : presentationData.strings.StoryList_TooltipStoriesSavedToChannel(Int32(paneNode.selectedIds.count))
text = isGroup ? presentationData.strings.Story_ToastSavedToGroupText : presentationData.strings.Story_ToastSavedToChannelText
}
environment.controller()?.present(UndoOverlayController(
presentationData: presentationData,
content: .info(title: title, text: text, timeout: nil, customUndoText: nil),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
paneNode.clearSelection()
}
})
}
)),
environment: {},
containerSize: availableSize
)
if let selectionPanelView = selectionPanel.view {
var animateIn = false
if selectionPanelView.superview == nil {
self.addSubview(selectionPanelView)
animateIn = true
}
selectionPanelTransition.setFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - selectionPanelSize.height), size: selectionPanelSize))
if animateIn {
transition.animatePosition(view: selectionPanelView, from: CGPoint(x: 0.0, y: selectionPanelSize.height), to: CGPoint(), additive: true)
}
}
bottomInset = selectionPanelSize.height
} else if let selectionPanel = self.selectionPanel {
self.selectionPanel = nil
if let selectionPanelView = selectionPanel.view {
transition.setPosition(view: selectionPanelView, position: CGPoint(x: selectionPanelView.center.x, y: availableSize.height + selectionPanelView.bounds.height * 0.5), completion: { [weak selectionPanelView] _ in
selectionPanelView?.removeFromSuperview()
})
}
}
let paneNode: PeerInfoStoryPaneNode
if let current = self.paneNode {
paneNode = current
} else {
paneNode = PeerInfoStoryPaneNode(
context: component.context,
scope: .peer(id: component.peerId, isSaved: true, isArchived: component.scope == .archive),
captureProtected: false,
isProfileEmbedded: false,
canManageStories: true,
excludeIds: component.excludeIds,
navigationController: { [weak self] in
guard let self else {
return nil
}
return self.environment?.controller()?.navigationController as? NavigationController
},
listContext: nil,
initialStoryFolderId: nil
)
paneNode.isEmptyUpdated = { [weak self] _ in
guard let self else {
return
}
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
}
self.paneNode = paneNode
if let selectionPanelView = self.selectionPanel?.view {
self.insertSubview(paneNode.view, belowSubview: selectionPanelView)
} else {
self.addSubview(paneNode.view)
}
paneNode.emptyAction = { [weak self] in
guard let self else {
return
}
self.openCreateStory()
}
paneNode.additionalEmptyAction = { [weak self] in
guard let self, let component = self.component else {
return
}
self.environment?.controller()?.push(PeerInfoStoryGridScreen(context: component.context, peerId: component.peerId, scope: .archive))
}
self.paneStatusDisposable = (paneNode.status
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let self else {
return
}
if self.paneStatusText != status?.text {
self.paneStatusText = status?.text
(self.environment?.controller() as? PeerInfoStoryGridScreen)?.updateTitle()
}
})
var applyState = false
self.selectionStateDisposable = (paneNode.updatedSelectedIds
|> distinctUntilChanged
|> deliverOnMainQueue).start(next: { [weak self] selectedIds in
guard let self else {
return
}
self.selectedCount = selectedIds.count
if applyState {
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
}
(self.environment?.controller() as? PeerInfoStoryGridScreen)?.updateTitle()
})
if component.selectionModeCompletion != nil {
paneNode.shouldOpenItemsWhileInSelectionMode = false
paneNode.setIsSelectionModeActive(true)
}
applyState = true
}
paneNode.update(
size: availableSize,
topInset: environment.navigationHeight,
sideInset: environment.safeInsets.left,
bottomInset: bottomInset,
deviceMetrics: environment.deviceMetrics,
visibleHeight: availableSize.height,
isScrollingLockedAtTop: false,
expandProgress: 1.0,
navigationHeight: 0.0,
presentationData: component.context.sharedContext.currentPresentationData.with({ $0 }),
synchronous: false,
transition: transition.containedViewLayoutTransition
)
transition.setFrame(view: paneNode.view, frame: CGRect(origin: CGPoint(), size: availableSize))
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public class PeerInfoStoryGridScreen: ViewControllerComponentContainer {
public enum Scope {
case saved
case archive
}
private let context: AccountContext
private let scope: Scope
private let selectionModeCompletion: (([EngineStoryItem]) -> Void)?
private var isDismissed: Bool = false
private var titleView: ChatTitleView?
private var moreBarButton: MoreHeaderButton?
private var moreBarButtonItem: UIBarButtonItem?
private var doneBarButtonItem: UIBarButtonItem?
public init(
context: AccountContext,
peerId: EnginePeer.Id,
scope: Scope,
excludeIds: [Int32] = [],
selectionModeCompletion: (([EngineStoryItem]) -> Void)? = nil
) {
self.context = context
self.scope = scope
self.selectionModeCompletion = selectionModeCompletion
super.init(context: context, component: PeerInfoStoryGridScreenComponent(
context: context,
peerId: peerId,
scope: scope,
excludeIds: excludeIds,
selectionModeCompletion: selectionModeCompletion
), navigationBarAppearance: .default, theme: .default)
let presentationData = context.sharedContext.currentPresentationData.with({ $0 })
let moreBarButton = MoreHeaderButton(color: presentationData.theme.rootController.navigationBar.buttonColor)
moreBarButton.isUserInteractionEnabled = true
self.moreBarButton = moreBarButton
moreBarButton.setContent(.more(MoreHeaderButton.optionsCircleImage(color: presentationData.theme.rootController.navigationBar.buttonColor)))
let moreBarButtonItem = UIBarButtonItem(customDisplayNode: self.moreBarButton)!
self.moreBarButtonItem = moreBarButtonItem
moreBarButton.contextAction = { [weak self] sourceNode, gesture in
guard let self else {
return
}
let _ = self
}
moreBarButton.addTarget(self, action: #selector(self.morePressed), forControlEvents: .touchUpInside)
let doneBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Done, style: .done, target: self, action: #selector(self.donePressed))
self.doneBarButtonItem = doneBarButtonItem
self.titleView = ChatTitleView(
context: context, theme:
presentationData.theme,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
animationCache: context.animationCache,
animationRenderer: context.animationRenderer
)
self.titleView?.disableAnimations = true
self.navigationItem.titleView = self.titleView
self.updateTitle()
self.scrollToTop = { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View else {
return
}
componentView.scrollToTop()
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func updateTitle() {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
if self.selectionModeCompletion != nil {
self.titleView?.titleContent = .custom(presentationData.strings.Stories_AddStoriesTitle, nil, false)
} else {
switch self.scope {
case .saved:
guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View, let paneNode = componentView.paneNode else {
return
}
let title: String?
if componentView.selectedCount != 0 {
title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount))
} else if let paneStatusText = componentView.paneStatusText, !paneStatusText.isEmpty {
title = paneStatusText
} else {
title = nil
}
self.titleView?.titleContent = .custom(presentationData.strings.StoryList_TitleSaved, title, false)
if paneNode.isSelectionModeActive {
self.navigationItem.setRightBarButton(self.doneBarButtonItem, animated: false)
} else {
self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false)
}
case .archive:
guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View else {
return
}
let title: String
if componentView.selectedCount != 0 {
title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount))
} else {
title = presentationData.strings.StoryList_TitleArchive
}
self.titleView?.titleContent = .custom(title, nil, false)
var hasMenu = false
if componentView.selectedCount != 0 {
hasMenu = true
} else if let paneNode = componentView.paneNode, !paneNode.isEmpty {
hasMenu = true
}
if hasMenu {
self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false)
} else {
self.navigationItem.setRightBarButton(nil, animated: false)
}
}
}
}
@objc private func morePressed() {
guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View else {
return
}
guard let moreBarButton = self.moreBarButton else {
return
}
componentView.morePressed(source: moreBarButton.referenceNode)
}
@objc private func donePressed() {
guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View, let paneNode = componentView.paneNode else {
return
}
paneNode.setIsSelectionModeActive(false)
self.updateTitle()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.titleView?.layout = layout
}
}
private final class PeerInfoContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceNode: ContextReferenceContentNode
init(controller: ViewController, sourceNode: ContextReferenceContentNode) {
self.controller = controller
self.sourceNode = sourceNode
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,308 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import ComponentFlow
import TelegramCore
import PeerInfoVisualMediaPaneNode
import ViewControllerComponent
import ChatListHeaderComponent
import ContextUI
import ChatTitleView
import BottomButtonPanelComponent
import UndoUI
import MoreHeaderButton
import SaveToCameraRoll
import ShareController
import OpenInExternalAppUI
final class StorySearchGridScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let scope: StorySearchControllerScope
let listContext: SearchStoryListContext?
init(
context: AccountContext,
scope: StorySearchControllerScope,
listContext: SearchStoryListContext?
) {
self.context = context
self.scope = scope
self.listContext = listContext
}
static func ==(lhs: StorySearchGridScreenComponent, rhs: StorySearchGridScreenComponent) -> Bool {
return true
}
final class View: UIView {
private var component: StorySearchGridScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private(set) var paneNode: PeerInfoStoryPaneNode?
private var paneStatusDisposable: Disposable?
private(set) var paneStatusText: String?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.paneStatusDisposable?.dispose()
}
func scrollToTop() {
guard let paneNode = self.paneNode else {
return
}
let _ = paneNode.scrollToTop()
}
private var isUpdating = false
func update(component: StorySearchGridScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
self.state = state
let sideInset: CGFloat = 14.0
let _ = sideInset
let environment = environment[EnvironmentType.self].value
let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment
if themeUpdated {
self.backgroundColor = environment.theme.list.plainBackgroundColor
}
let bottomInset: CGFloat = environment.safeInsets.bottom
let paneNode: PeerInfoStoryPaneNode
if let current = self.paneNode {
paneNode = current
} else {
let paneNodeScope: PeerInfoStoryPaneNode.Scope
switch component.scope {
case let .query(peer, query):
paneNodeScope = .search(peerId: peer?.id, query: query)
case let .location(coordinates, venue):
paneNodeScope = .location(coordinates: coordinates, venue: venue)
}
paneNode = PeerInfoStoryPaneNode(
context: component.context,
scope: paneNodeScope,
captureProtected: false,
isProfileEmbedded: false,
canManageStories: false,
navigationController: { [weak self] in
guard let self else {
return nil
}
return self.environment?.controller()?.navigationController as? NavigationController
},
listContext: component.listContext,
initialStoryFolderId: nil
)
paneNode.parentController = environment.controller()
paneNode.isEmptyUpdated = { [weak self] _ in
guard let self else {
return
}
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
}
self.paneNode = paneNode
self.addSubview(paneNode.view)
self.paneStatusDisposable = (paneNode.status
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let self else {
return
}
if self.paneStatusText != status?.text {
self.paneStatusText = status?.text
(self.environment?.controller() as? StorySearchGridScreen)?.updateTitle()
}
})
}
paneNode.update(
size: availableSize,
topInset: environment.navigationHeight,
sideInset: environment.safeInsets.left,
bottomInset: bottomInset,
deviceMetrics: environment.deviceMetrics,
visibleHeight: availableSize.height,
isScrollingLockedAtTop: false,
expandProgress: 1.0,
navigationHeight: 0.0,
presentationData: component.context.sharedContext.currentPresentationData.with({ $0 }),
synchronous: false,
transition: transition.containedViewLayoutTransition
)
transition.setFrame(view: paneNode.view, frame: CGRect(origin: CGPoint(), size: availableSize))
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class StorySearchGridScreen: ViewControllerComponentContainer {
private let context: AccountContext
private let scope: StorySearchControllerScope
private var isDismissed: Bool = false
private var titleView: ChatTitleView?
override public var additionalNavigationBarHeight: CGFloat {
if let componentView = self.node.hostView.componentView as? StorySearchGridScreenComponent.View, let paneNode = componentView.paneNode {
return paneNode.additionalNavigationHeight
} else {
return 0.0
}
}
public init(
context: AccountContext,
scope: StorySearchControllerScope,
listContext: SearchStoryListContext? = nil
) {
self.context = context
self.scope = scope
super.init(context: context, component: StorySearchGridScreenComponent(
context: context,
scope: scope,
listContext: listContext
), navigationBarAppearance: .default, theme: .default)
let presentationData = context.sharedContext.currentPresentationData.with({ $0 })
self.titleView = ChatTitleView(
context: context, theme:
presentationData.theme,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
animationCache: context.animationCache,
animationRenderer: context.animationRenderer
)
self.titleView?.disableAnimations = true
self.navigationItem.titleView = self.titleView
if case .location = scope {
self.navigationItem.setRightBarButton(UIBarButtonItem(image: PresentationResourcesRootController.navigationShareIcon(presentationData.theme), style: .plain, target: self, action: #selector(self.sharePressed)), animated: true)
}
self.updateTitle()
self.scrollToTop = { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? StorySearchGridScreenComponent.View else {
return
}
componentView.scrollToTop()
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
@objc private func sharePressed() {
guard case let .location(_, venue) = self.scope else {
return
}
let locationMap = TelegramMediaMap(latitude: venue.latitude, longitude: venue.longitude, heading: nil, accuracyRadius: nil, venue: nil, address: venue.address, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil)
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 })
let shareAction = OpenInControllerAction(title: presentationData.strings.Conversation_ContextMenuShare, action: { [weak self] in
guard let self else {
return
}
self.present(ShareController(context: self.context, subject: .mapMedia(locationMap), externalShare: true), in: .window(.root), with: nil)
})
self.present(OpenInActionSheetController(context: self.context, updatedPresentationData: nil, item: .location(location: locationMap, directions: nil), additionalAction: shareAction, openUrl: { [weak self] url in
guard let self else {
return
}
self.context.sharedContext.applicationBindings.openUrl(url)
}), in: .window(.root), with: nil)
}
func updateTitle() {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
guard let componentView = self.node.hostView.componentView as? StorySearchGridScreenComponent.View, let paneNode = componentView.paneNode else {
return
}
let _ = paneNode
let title: String?
if let paneStatusText = componentView.paneStatusText, !paneStatusText.isEmpty {
title = paneStatusText
} else {
title = nil
}
switch self.scope {
case let .query(peer, query):
if let peer, let addressName = peer.addressName {
self.titleView?.titleContent = .custom("\(query)@\(addressName)", title, false)
} else {
self.titleView?.titleContent = .custom("\(query)", title, false)
}
case .location:
self.titleView?.titleContent = .custom(presentationData.strings.StoryGridScreen_TitleLocationSearch, nil, false)
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.titleView?.layout = layout
}
}
private final class PeerInfoContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceNode: ContextReferenceContentNode
init(controller: ViewController, sourceNode: ContextReferenceContentNode) {
self.controller = controller
self.sourceNode = sourceNode
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,66 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerInfoVisualMediaPaneNode",
module_name = "PeerInfoVisualMediaPaneNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AsyncDisplayKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/ContextUI",
"//submodules/PhotoResources",
"//submodules/RadialStatusNode",
"//submodules/TelegramStringFormatting",
"//submodules/GridMessageSelectionNode",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/ListMessageItem",
"//submodules/ChatMessageInteractiveMediaBadge",
"//submodules/SparseItemGrid",
"//submodules/ShimmerEffect",
"//submodules/DirectMediaImageCache",
"//submodules/ComponentFlow",
"//submodules/TelegramNotices",
"//submodules/TelegramUIPreferences",
"//submodules/CheckNode",
"//submodules/AppBundle",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/ChatControllerInteraction",
"//submodules/InvisibleInkDustNode",
"//submodules/MediaPickerUI",
"//submodules/TelegramUI/Components/Stories/StoryContainerScreen",
"//submodules/TelegramUI/Components/EmptyStateIndicatorComponent",
"//submodules/UIKitRuntimeUtils",
"//submodules/TelegramUI/Components/PeerInfo/PeerInfoPaneNode",
"//submodules/ShareController",
"//submodules/UndoUI",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/MediaEditorScreen",
"//submodules/LocationUI",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/Settings/LanguageSelectionScreen",
"//submodules/TelegramUI/Components/Gifts/GiftItemComponent",
"//submodules/TelegramUI/Components/Gifts/GiftViewScreen",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/TelegramUI/Components/CheckComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/BottomButtonPanelComponent",
"//submodules/PromptUI",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent"
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,525 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import AccountContext
import ComponentFlow
import ViewControllerComponent
import BundleIconComponent
import MultilineTextComponent
import ButtonComponent
import BlurredBackgroundComponent
import ContextUI
final class AddGiftsScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let peerId: EnginePeer.Id
let collectionId: Int32
let remainingCount: Int32
let profileGifts: ProfileGiftsContext
init(
context: AccountContext,
peerId: EnginePeer.Id,
collectionId: Int32,
remainingCount: Int32,
profileGifts: ProfileGiftsContext
) {
self.context = context
self.peerId = peerId
self.collectionId = collectionId
self.remainingCount = remainingCount
self.profileGifts = profileGifts
}
static func ==(lhs: AddGiftsScreenComponent, rhs: AddGiftsScreenComponent) -> Bool {
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
private let backgroundView: UIView
private let scrollView: ScrollView
private var giftsListView: GiftsListView?
private let buttonBackground = ComponentView<Empty>()
private let buttonSeparator = SimpleLayer()
private let button = ComponentView<Empty>()
private var isUpdating: Bool = false
private var component: AddGiftsScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
override init(frame: CGRect) {
self.backgroundView = UIView()
self.scrollView = ScrollView()
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.scrollsToTop = false
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.alwaysBounceVertical = true
super.init(frame: frame)
self.addSubview(self.backgroundView)
self.scrollView.delegate = self
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func scrollToTop() {
self.scrollView.setContentOffset(CGPoint(), animated: true)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateScrolling(interactive: true, transition: .immediate)
}
private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) {
guard let environment = self.environment, let giftsListView = self.giftsListView else {
return
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0)
let contentHeight = giftsListView.updateScrolling(topInset: environment.navigationHeight + 10.0, visibleBounds: visibleBounds, transition: transition)
var contentSize = CGSize(width: self.scrollView.bounds.width, height: contentHeight)
contentSize.height += environment.safeInsets.bottom
contentSize.height = max(contentSize.height, self.scrollView.bounds.size.height)
contentSize.height += 50.0 + 24.0
transition.setFrame(view: giftsListView, frame: CGRect(origin: CGPoint(), size: contentSize))
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
let bottomContentOffset = max(0.0, self.scrollView.contentSize.height - self.scrollView.contentOffset.y - self.scrollView.frame.height)
if interactive, bottomContentOffset < 200.0 {
self.giftsListView?.loadMore()
}
}
func update(component: AddGiftsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let giftsListView: GiftsListView
if let current = self.giftsListView {
giftsListView = current
} else {
giftsListView = GiftsListView(context: component.context, peerId: component.peerId, profileGifts: component.profileGifts, giftsCollections: nil, canSelect: true, ignoreCollection: component.collectionId, remainingSelectionCount: component.remainingCount)
giftsListView.onContentUpdated = { [weak self] in
guard let self else {
return
}
self.state?.updated(transition: .immediate)
}
giftsListView.selectionUpdated = { [weak self] in
guard let self else {
return
}
self.state?.updated(transition: .spring(duration: 0.4))
}
self.scrollView.addSubview(giftsListView)
self.giftsListView = giftsListView
}
let environment = environment[EnvironmentType.self].value
self.environment = environment
self.component = component
self.state = state
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let buttonHeight: CGFloat = 50.0
let bottomPanelPadding: CGFloat = 12.0
let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding
let bottomPanelHeight = bottomPanelPadding + buttonHeight + bottomInset
let bottomPanelOffset: CGFloat = giftsListView.selectedItems.count > 0 ? 0.0 : bottomPanelHeight
let buttonString = environment.strings.AddGifts_AddGifts(Int32(giftsListView.selectedItems.count))
let bottomPanelSize = self.buttonBackground.update(
transition: transition,
component: AnyComponent(BlurredBackgroundComponent(
color: environment.theme.rootController.tabBar.backgroundColor
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: bottomPanelHeight)
)
self.buttonSeparator.backgroundColor = environment.theme.rootController.tabBar.separatorColor.cgColor
if let view = self.buttonBackground.view {
if view.superview == nil {
self.addSubview(view)
self.layer.addSublayer(self.buttonSeparator)
}
transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: bottomPanelSize))
transition.setFrame(layer: self.buttonSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: CGSize(width: availableSize.width, height: UIScreenPixel)))
}
let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: environment.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)
let buttonSize = self.button.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
color: environment.theme.list.itemCheckColors.fillColor,
foreground: environment.theme.list.itemCheckColors.foregroundColor,
pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 10.0
),
content: AnyComponentWithIdentity(
id: AnyHashable(buttonAttributedString.string),
component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))
),
action: { [weak self] in
guard let self, let controller = self.environment?.controller() as? AddGiftsScreen, let giftsListView = self.giftsListView else {
return
}
controller.completion(giftsListView.selectedItems)
controller.dismiss(animated: true)
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: buttonHeight)
)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - buttonSize.width) / 2.0), y: availableSize.height - bottomPanelHeight + bottomPanelPadding + bottomPanelOffset), size: buttonSize))
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0)
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 })
let _ = giftsListView.update(size: availableSize, sideInset: 0.0, bottomInset: max(environment.safeInsets.bottom, bottomPanelHeight), deviceMetrics: environment.deviceMetrics, visibleHeight: availableSize.height, isScrollingLockedAtTop: false, expandProgress: 0.0, presentationData: presentationData, synchronous: false, visibleBounds: visibleBounds, transition: transition.containedViewLayoutTransition)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: .zero, size: availableSize))
self.backgroundView.backgroundColor = environment.theme.list.blocksBackgroundColor
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize))
self.updateScrolling(transition: transition)
return availableSize
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let r = super.hitTest(point, with: event)
return r
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class AddGiftsScreen: ViewControllerComponentContainer {
private let context: AccountContext
private let peerId: EnginePeer.Id
private let collectionId: Int32
fileprivate let completion: ([ProfileGiftsContext.State.StarGift]) -> Void
private let profileGifts: ProfileGiftsContext
private let filterButton: FilterHeaderButton
public init(
context: AccountContext,
peerId: EnginePeer.Id,
collectionId: Int32,
remainingCount: Int32,
completion: @escaping ([ProfileGiftsContext.State.StarGift]) -> Void
) {
self.context = context
self.peerId = peerId
self.collectionId = collectionId
self.completion = completion
self.profileGifts = ProfileGiftsContext(account: context.account, peerId: peerId)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.filterButton = FilterHeaderButton(presentationData: presentationData)
super.init(context: context, component: AddGiftsScreenComponent(
context: context,
peerId: peerId,
collectionId: collectionId,
remainingCount: remainingCount,
profileGifts: self.profileGifts
), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil)
self.title = presentationData.strings.AddGifts_Title
self.navigationPresentation = .modal
self.scrollToTop = { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? AddGiftsScreenComponent.View else {
return
}
componentView.scrollToTop()
}
self.filterButton.contextAction = { [weak self] sourceNode, gesture in
self?.presentContextMenu(sourceView: sourceNode.view, gesture: gesture)
}
self.filterButton.addTarget(self, action: #selector(self.filterPressed), forControlEvents: .touchUpInside)
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.filterButton)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func presentContextMenu(sourceView: UIView, gesture: ContextGesture?) {
let giftsContext = self.profileGifts
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
let items: Signal<ContextController.Items, NoError> = giftsContext.state
|> map { state in
var hasPinnedGifts = false
for gift in state.gifts {
if gift.pinnedToTop {
hasPinnedGifts = true
break
}
}
return (state.filter, state.sorting, hasPinnedGifts)
}
|> distinctUntilChanged(isEqual: { lhs, rhs -> Bool in
let filterEquals = lhs.0 == rhs.0
let sortingEquals = lhs.1 == rhs.1
let hasPinnedGiftsEquals = lhs.2 == rhs.2
return filterEquals && sortingEquals && hasPinnedGiftsEquals
})
|> map { [weak giftsContext] filter, sorting, hasPinnedGifts -> ContextController.Items in
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: sorting == .date ? strings.PeerInfo_Gifts_SortByValue : strings.PeerInfo_Gifts_SortByDate, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: sorting == .date ? "Peer Info/SortValue" : "Peer Info/SortDate"), color: theme.contextMenu.primaryColor)
}, action: { [weak giftsContext] _, f in
f(.default)
giftsContext?.updateSorting(sorting == .date ? .value : .date)
})))
items.append(.separator)
let toggleFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in
var updatedFilter = filter
if updatedFilter.contains(value) {
updatedFilter.remove(value)
} else {
updatedFilter.insert(value)
}
if !updatedFilter.contains(.unlimited) && !updatedFilter.contains(.limitedUpgradable) && !updatedFilter.contains(.limitedNonUpgradable) && !updatedFilter.contains(.unique) {
updatedFilter.insert(.unlimited)
}
if !updatedFilter.contains(.displayed) && !updatedFilter.contains(.hidden) {
if value == .displayed {
updatedFilter.insert(.hidden)
} else {
updatedFilter.insert(.displayed)
}
}
giftsContext?.updateFilter(updatedFilter)
}
let switchToFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in
var updatedFilter = filter
updatedFilter.remove(.unlimited)
updatedFilter.remove(.limitedUpgradable)
updatedFilter.remove(.limitedNonUpgradable)
updatedFilter.remove(.unique)
updatedFilter.insert(value)
giftsContext?.updateFilter(updatedFilter)
}
let switchToVisiblityFilter: (ProfileGiftsContext.Filters) -> Void = { [weak giftsContext] value in
var updatedFilter = filter
updatedFilter.remove(.hidden)
updatedFilter.remove(.displayed)
updatedFilter.insert(value)
giftsContext?.updateFilter(updatedFilter)
}
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Unlimited, icon: { theme in
return filter.contains(.unlimited) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.unlimited)
}, longPressAction: { _, f in
switchToFilter(.unlimited)
})))
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Limited, icon: { theme in
return filter.contains(.limitedNonUpgradable) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.limitedNonUpgradable)
}, longPressAction: { _, f in
switchToFilter(.limitedNonUpgradable)
})))
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Upgradable, icon: { theme in
return filter.contains(.limitedUpgradable) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.limitedUpgradable)
}, longPressAction: { _, f in
switchToFilter(.limitedUpgradable)
})))
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Unique, icon: { theme in
return filter.contains(.unique) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.unique)
}, longPressAction: { _, f in
switchToFilter(.unique)
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Displayed, icon: { theme in
return filter.contains(.displayed) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.displayed)
}, longPressAction: { _, f in
switchToVisiblityFilter(.displayed)
})))
items.append(.action(ContextMenuActionItem(text: strings.PeerInfo_Gifts_Hidden, icon: { theme in
return filter.contains(.hidden) ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil
}, action: { _, f in
toggleFilter(.hidden)
}, longPressAction: { _, f in
switchToVisiblityFilter(.hidden)
})))
return ContextController.Items(content: .list(items))
}
let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: items, gesture: gesture)
self.presentInGlobalOverlay(contextController)
}
deinit {
}
@objc private func cancelPressed() {
self.dismiss()
}
@objc private func filterPressed() {
self.filterButton.contextAction?(self.filterButton.containerNode, nil)
}
}
private final class FilterHeaderButton: HighlightableButtonNode {
let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
private let icon = ComponentView<Empty>()
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
init(presentationData: PresentationData) {
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
super.init()
self.containerNode.addSubnode(self.referenceNode)
self.addSubnode(self.containerNode)
self.containerNode.shouldBegin = { [weak self] location in
guard let strongSelf = self, let _ = strongSelf.contextAction else {
return false
}
return true
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.contextAction?(strongSelf.containerNode, gesture)
}
self.update(theme: presentationData.theme, strings: presentationData.strings)
}
func update(theme: PresentationTheme, strings: PresentationStrings) {
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(
BundleIconComponent(
name: "Peer Info/SortIcon",
tintColor: theme.rootController.navigationBar.accentTextColor
)
),
environment: {},
containerSize: CGSize(width: 30.0, height: 30.0)
)
if let view = self.icon.view {
if view.superview == nil {
view.isUserInteractionEnabled = false
self.referenceNode.view.addSubview(view)
}
view.frame = CGRect(origin: CGPoint(x: 14.0, y: 7.0), size: iconSize)
}
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 44.0, height: 44.0))
self.referenceNode.frame = self.containerNode.bounds
}
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
return CGSize(width: 44.0, height: 44.0)
}
func onLayout() {
}
}
private final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceView: UIView
init(controller: ViewController, sourceView: UIView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,277 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import ViewControllerComponent
import ComponentDisplayAdapters
import TelegramPresentationData
import TelegramStringFormatting
import AccountContext
import TelegramCore
import MultilineTextComponent
import GiftAnimationComponent
private final class GiftContextPreviewComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let gift: ProfileGiftsContext.State.StarGift
init(
context: AccountContext,
gift: ProfileGiftsContext.State.StarGift
) {
self.context = context
self.gift = gift
}
static func ==(lhs: GiftContextPreviewComponent, rhs: GiftContextPreviewComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.gift != rhs.gift {
return false
}
return true
}
public final class View: UIView {
private let animation = ComponentView<Empty>()
private let giftCompositionExternalState = GiftCompositionComponent.ExternalState()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private var component: GiftContextPreviewComponent?
private weak var state: EmptyComponentState?
private var environment: ViewControllerComponentContainer.Environment?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: GiftContextPreviewComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
self.component = component
self.state = state
let subject: GiftCompositionComponent.Subject
switch component.gift.gift {
case let .generic(gift):
subject = .generic(gift.file)
case let .unique(gift):
subject = .unique(nil, gift)
}
let animationSize = self.animation.update(
transition: .immediate,
component: AnyComponent(GiftCompositionComponent(
context: component.context,
theme: environment.theme,
subject: subject,
animationOffset: nil,
animationScale: nil,
displayAnimationStars: false,
externalState: self.giftCompositionExternalState,
requestUpdate: { [weak state] _ in
state?.updated()
}
)),
environment: {},
containerSize: availableSize
)
if let view = self.animation.view {
if view.superview == nil {
self.addSubview(view)
}
view.frame = CGRect(origin: .zero, size: animationSize)
}
if case let .unique(uniqueGift) = component.gift.gift {
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(
NSAttributedString(string: uniqueGift.title, font: Font.semibold(20.0), textColor: .white)
))),
environment: {},
containerSize: availableSize
)
if let view = self.title.view {
if view.superview == nil {
self.addSubview(view)
}
view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: availableSize.height - titleSize.height - 40.0), size: titleSize)
}
let vibrantColor: UIColor
if let previewPatternColor = giftCompositionExternalState.previewPatternColor {
vibrantColor = previewPatternColor.withMultiplied(hue: 1.0, saturation: 1.02, brightness: 1.25).mixedWith(UIColor.white, alpha: 0.3)
} else {
vibrantColor = UIColor.white.withAlphaComponent(0.6)
}
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(
NSAttributedString(string: "\(environment.strings.Gift_Unique_Collectible) #\(formatCollectibleNumber(uniqueGift.number, dateTimeFormat: environment.dateTimeFormat))", font: Font.regular(13.0), textColor: vibrantColor)
))),
environment: {},
containerSize: availableSize
)
if let view = self.subtitle.view {
if view.superview == nil {
self.addSubview(view)
}
view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - subtitleSize.width) / 2.0), y: availableSize.height - subtitleSize.height - 20.0), size: subtitleSize)
}
}
return availableSize
}
}
func makeView() -> View {
return View()
}
public func update(view: View, availableSize: CGSize, state: State, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class GiftContextPreviewController: ViewController {
fileprivate final class Node: ViewControllerTracingNode, ASGestureRecognizerDelegate {
private weak var controller: GiftContextPreviewController?
fileprivate let componentHost: ComponentView<ViewControllerComponentContainer.Environment>
private var presentationData: PresentationData
private var validLayout: ContainerViewLayout?
init(controller: GiftContextPreviewController) {
self.controller = controller
self.presentationData = controller.context.sharedContext.currentPresentationData.with { $0 }
self.componentHost = ComponentView<ViewControllerComponentContainer.Environment>()
super.init()
self.backgroundColor = .clear
}
override func didLoad() {
super.didLoad()
self.view.disablesInteractiveModalDismiss = true
self.view.disablesInteractiveKeyboardGestureRecognizer = true
}
func requestLayout(transition: ComponentTransition) {
if let layout = self.validLayout {
self.containerLayoutUpdated(layout: layout, forceUpdate: true, transition: transition)
}
}
func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, animateOut: Bool = false, transition: ComponentTransition) {
guard let controller = self.controller else {
return
}
self.validLayout = layout
let environment = ViewControllerComponentContainer.Environment(
statusBarHeight: 0.0,
navigationHeight: 0.0,
safeInsets: layout.safeInsets,
additionalInsets: layout.additionalInsets,
inputHeight: 0.0,
metrics: layout.metrics,
deviceMetrics: layout.deviceMetrics,
orientation: nil,
isVisible: true,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
dateTimeFormat: self.presentationData.dateTimeFormat,
controller: { [weak self] in
return self?.controller
}
)
let componentSize = self.componentHost.update(
transition: transition,
component: AnyComponent(
GiftContextPreviewComponent(
context: controller.context,
gift: controller.gift
)
),
environment: {
environment
},
forceUpdate: forceUpdate,
containerSize: layout.size
)
if let componentView = self.componentHost.view {
if componentView.superview == nil {
componentView.clipsToBounds = true
self.view.addSubview(componentView)
}
let componentFrame = CGRect(origin: .zero, size: componentSize)
transition.setFrame(view: componentView, frame: CGRect(origin: componentFrame.origin, size: CGSize(width: componentFrame.width, height: componentFrame.height)))
}
}
}
fileprivate var node: Node {
return self.displayNode as! Node
}
fileprivate let context: AccountContext
fileprivate let gift: ProfileGiftsContext.State.StarGift
init(
context: AccountContext,
gift: ProfileGiftsContext.State.StarGift
) {
self.context = context
self.gift = gift
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadDisplayNode() {
self.displayNode = Node(controller: self)
super.displayNodeDidLoad()
}
override func preferredContentSizeForLayout(_ layout: ContainerViewLayout) -> CGSize? {
let minSide = min(layout.size.width, layout.size.height)
if case .unique = self.gift.gift {
return CGSize(width: minSide, height: floor(minSide * 0.66))
} else {
return CGSize(width: minSide, height: 180.0)
}
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
(self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition))
}
}
@@ -0,0 +1,39 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PostSuggestionsSettingsScreen",
module_name = "PostSuggestionsSettingsScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/TelegramPresentationData",
"//submodules/ComponentFlow",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/AppBundle",
"//submodules/Components/ViewControllerComponent",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/AccountContext",
"//submodules/TelegramUI/Components/SwitchComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/Markdown",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/TextFormat",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListSwitchItemComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/TelegramStringFormatting",
"//submodules/TelegramUI/Components/ListItemComponentAdaptor",
"//submodules/TelegramUI/Components/PeerInfo/MessagePriceItem",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,548 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import AccountContext
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import BalancedTextComponent
import ListSectionComponent
import BundleIconComponent
import LottieComponent
import ListSwitchItemComponent
import ListActionItemComponent
import Markdown
import TelegramStringFormatting
import MessagePriceItem
import ListItemComponentAdaptor
final class PostSuggestionsSettingsScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let usdWithdrawRate: Int64
let channelMessageSuggestionCommissionPermille: Int
let peer: EnginePeer?
let initialPrice: StarsAmount?
let completion: () -> Void
init(
context: AccountContext,
usdWithdrawRate: Int64,
channelMessageSuggestionCommissionPermille: Int,
peer: EnginePeer?,
initialPrice: StarsAmount?,
completion: @escaping () -> Void
) {
self.context = context
self.usdWithdrawRate = usdWithdrawRate
self.channelMessageSuggestionCommissionPermille = channelMessageSuggestionCommissionPermille
self.peer = peer
self.initialPrice = initialPrice
self.completion = completion
}
static func ==(lhs: PostSuggestionsSettingsScreenComponent, rhs: PostSuggestionsSettingsScreenComponent) -> Bool {
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
private let topOverscrollLayer = SimpleLayer()
private let scrollView: ScrollView
private let navigationTitle = ComponentView<Empty>()
private let icon = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private let switchSection = ComponentView<Empty>()
private let contentSection = ComponentView<Empty>()
private var isUpdating: Bool = false
private var component: PostSuggestionsSettingsScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private var areSuggestionsEnabled: Bool = false
private var starCount: Int = 0
override init(frame: CGRect) {
self.scrollView = ScrollView()
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.scrollsToTop = false
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.alwaysBounceVertical = true
super.init(frame: frame)
self.scrollView.delegate = self
self.addSubview(self.scrollView)
self.scrollView.layer.addSublayer(self.topOverscrollLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func scrollToTop() {
self.scrollView.setContentOffset(CGPoint(), animated: true)
}
func attemptNavigation(complete: @escaping () -> Void) -> Bool {
guard let component = self.component else {
return true
}
guard let peer = component.peer else {
return true
}
let currentAmount: StarsAmount?
if self.areSuggestionsEnabled {
currentAmount = StarsAmount(value: Int64(self.starCount), nanos: 0)
} else {
currentAmount = nil
}
if component.initialPrice != currentAmount {
let _ = component.context.engine.peers.updateChannelPaidMessagesStars(peerId: peer.id, stars: currentAmount, broadcastMessagesAllowed: currentAmount != nil).startStandalone()
}
return true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateScrolling(transition: .immediate)
}
var scrolledUp = true
private func updateScrolling(transition: ComponentTransition) {
let navigationRevealOffsetY: CGFloat = 0.0
let navigationAlphaDistance: CGFloat = 16.0
let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance))
if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar {
transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha)
transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha)
}
var scrolledUp = false
if navigationAlpha < 0.5 {
scrolledUp = true
} else if navigationAlpha > 0.5 {
scrolledUp = false
}
if self.scrolledUp != scrolledUp {
self.scrolledUp = scrolledUp
if !self.isUpdating {
self.state?.updated()
}
}
if let navigationTitleView = self.navigationTitle.view {
transition.setAlpha(view: navigationTitleView, alpha: 1.0)
}
}
func update(component: PostSuggestionsSettingsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
if self.component == nil {
if let initialPrice = component.initialPrice {
self.starCount = Int(initialPrice.value)
self.areSuggestionsEnabled = true
} else {
self.starCount = 20
if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["stars_paid_messages_channel_amount_default"] as? Double {
self.starCount = Int(value)
}
self.areSuggestionsEnabled = false
}
}
let environment = environment[EnvironmentType.self].value
let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment
self.component = component
self.state = state
let alphaTransition: ComponentTransition
if !transition.animation.isImmediate {
alphaTransition = .easeInOut(duration: 0.25)
} else {
alphaTransition = .immediate
}
if themeUpdated {
self.backgroundColor = environment.theme.list.blocksBackgroundColor
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let navigationTitleSize = self.navigationTitle.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: environment.strings.ChannelMessages_Title, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
horizontalAlignment: .center
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize)
if let navigationTitleView = self.navigationTitle.view {
if navigationTitleView.superview == nil {
if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar {
navigationBar.view.addSubview(navigationTitleView)
}
}
transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame)
}
let bottomContentInset: CGFloat = 24.0
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let sectionSpacing: CGFloat = 24.0
var contentHeight: CGFloat = 0.0
contentHeight += environment.navigationHeight
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "ChannelMessages"),
loop: false
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 11.0), size: iconSize)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.scrollView.addSubview(iconView)
iconView.playOnce()
}
transition.setPosition(view: iconView, position: iconFrame.center)
iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
}
contentHeight += 129.0
let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.ChannelMessages_Info, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor),
bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor),
link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor),
linkAttribute: { attributes in
return ("URL", "")
}), textAlignment: .center
))
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(subtitleString),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.25,
highlightColor: environment.theme.list.itemAccentColor.withMultipliedAlpha(0.1),
highlightAction: { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] {
return NSAttributedString.Key(rawValue: "URL")
} else {
return nil
}
},
tapAction: { [weak self] _, _ in
guard let self, let component = self.component else {
return
}
let _ = component
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.scrollView.addSubview(subtitleView)
}
transition.setPosition(view: subtitleView, position: subtitleFrame.center)
subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size)
}
contentHeight += subtitleSize.height
contentHeight += 27.0
var switchSectionItems: [AnyComponentWithIdentity<Empty>] = []
switchSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
style: .glass,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.ChannelMessages_SwitchTitle,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.areSuggestionsEnabled, isInteractive: false)),
action: { [weak self] _ in
guard let self else {
return
}
self.areSuggestionsEnabled = !self.areSuggestionsEnabled
self.state?.updated(transition: .spring(duration: 0.4))
}
))))
let switchSectionSize = self.switchSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: environment.theme,
style: .glass,
header: nil,
footer: nil,
items: switchSectionItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let switchSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: switchSectionSize)
if let switchSectionView = self.switchSection.view {
if switchSectionView.superview == nil {
self.scrollView.addSubview(switchSectionView)
self.switchSection.parentState = state
}
transition.setFrame(view: switchSectionView, frame: switchSectionFrame)
}
contentHeight += switchSectionSize.height
contentHeight += sectionSpacing
var contentSectionItems: [AnyComponentWithIdentity<Empty>] = []
let usdRate = Double(component.usdWithdrawRate) / 1000.0 / 100.0
let price = self.starCount == 0 ? "" : "~\(formatTonUsdValue(Int64(self.starCount), divide: false, rate: usdRate, dateTimeFormat: presentationData.dateTimeFormat))"
contentSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemComponentAdaptor(
itemGenerator: MessagePriceItem(
theme: environment.theme,
strings: environment.strings,
isEnabled: true, minValue: 0, maxValue: 10000,
value: Int64(self.starCount),
price: price,
sectionId: 0,
updated: { [weak self] value, _ in
guard let self else {
return
}
self.starCount = Int(value)
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
},
openSetCustom: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
return
}
let currentAmount: StarsAmount = StarsAmount(value: Int64(self.starCount), nanos: 0)
let starsScreen = component.context.sharedContext.makeStarsWithdrawalScreen(context: component.context, subject: .enterAmount(current: currentAmount, minValue: StarsAmount(value: 0, nanos: 0), fractionAfterCommission: component.channelMessageSuggestionCommissionPermille / 10, kind: .postSuggestion, completion: { [weak self] amount in
guard let self else {
return
}
self.starCount = Int(amount)
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
}))
environment.controller()?.push(starsScreen)
},
openPremiumInfo: nil
),
params: ListViewItemLayoutParams(width: availableSize.width - sideInset * 2.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true)
))))
let contentSectionSize = self.contentSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: environment.theme,
style: .glass,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.ChannelMessages_PriceSectionTitle,
font: Font.regular(13.0),
textColor: environment.theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.ChannelMessages_PriceSectionFooterValue("\(component.channelMessageSuggestionCommissionPermille / 10)").string,
font: Font.regular(13.0),
textColor: self.starCount == 0 ? .clear : environment.theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
items: contentSectionItems,
displaySeparators: false
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let contentSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: contentSectionSize)
if let contentSectionView = self.contentSection.view {
if contentSectionView.superview == nil {
self.scrollView.addSubview(contentSectionView)
}
transition.setFrame(view: contentSectionView, frame: contentSectionFrame)
alphaTransition.setAlpha(view: contentSectionView, alpha: self.areSuggestionsEnabled ? 1.0 : 0.0)
}
if self.areSuggestionsEnabled {
contentHeight += contentSectionSize.height
}
contentHeight += bottomContentInset
contentHeight += environment.safeInsets.bottom
let previousBounds = self.scrollView.bounds
let contentSize = CGSize(width: availableSize.width, height: contentHeight)
if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) {
self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize)
}
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0)
if self.scrollView.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
if !previousBounds.isEmpty, !transition.animation.isImmediate {
let bounds = self.scrollView.bounds
if bounds.maxY != previousBounds.maxY {
let offsetY = previousBounds.maxY - bounds.maxY
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true)
}
}
self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0))
self.updateScrolling(transition: transition)
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@available(iOS 13.0, *)
public final class PostSuggestionsSettingsScreen: ViewControllerComponentContainer {
private let context: AccountContext
@MainActor
public init(
context: AccountContext,
peerId: EnginePeer.Id,
completion: @escaping () -> Void
) async {
self.context = context
let configuration = StarsSubscriptionConfiguration.with(appConfiguration: context.currentAppConfiguration.with({ $0 }))
let peer = await context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
).get()
let initialPrice: StarsAmount?
if case let .channel(channel) = peer, case let .broadcast(info) = channel.info, info.flags.contains(.hasMonoforum), let linkedMonoforumId = channel.linkedMonoforumId {
initialPrice = await context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.SendMessageToChannelPrice(id: linkedMonoforumId)
).get() ?? StarsAmount(value: 0, nanos: 0)
} else {
initialPrice = await context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.SendMessageToChannelPrice(id: peerId)
).get()
}
super.init(context: context, component: PostSuggestionsSettingsScreenComponent(
context: context,
usdWithdrawRate: configuration.usdWithdrawRate,
channelMessageSuggestionCommissionPermille: Int(configuration.paidMessageCommissionPermille),
peer: peer,
initialPrice: initialPrice,
completion: completion
), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.title = ""
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
self.scrollToTop = { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? PostSuggestionsSettingsScreenComponent.View else {
return
}
componentView.scrollToTop()
}
self.attemptNavigation = { [weak self] complete in
guard let self, let componentView = self.node.hostView.componentView as? PostSuggestionsSettingsScreenComponent.View else {
return true
}
return componentView.attemptNavigation(complete: complete)
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
@objc private func cancelPressed() {
self.dismiss()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
}
}
@@ -0,0 +1,38 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ProfileLevelInfoScreen",
module_name = "ProfileLevelInfoScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/TelegramPresentationData",
"//submodules/PresentationDataUtils",
"//submodules/ComponentFlow",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/AppBundle",
"//submodules/Markdown",
"//submodules/AccountContext",
"//submodules/TelegramCore",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/PremiumUI",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/Utils/RoundedRectWithTailPath",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/TelegramUI/Components/PeerInfo/ProfileLevelRatingBarComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,32 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ProfileLevelRatingBarComponent",
module_name = "ProfileLevelRatingBarComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Display",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/ComponentFlow",
"//submodules/TelegramUI/Components/MultiAnimationRenderer",
"//submodules/TelegramUI/Components/AnimationCache",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/TelegramUI/Components/LottieComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,333 @@
import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import AnimatedTextComponent
import MultilineTextComponent
import LottieComponent
final class ProfileLevelRatingBarBadge: Component {
final class TransitionHint {
let animateText: Bool
init(animateText: Bool) {
self.animateText = animateText
}
}
enum Icon {
case rating
case stars
}
let theme: PresentationTheme
let title: String
let suffix: String?
let icon: Icon
init(
theme: PresentationTheme,
title: String,
suffix: String?,
icon: Icon
) {
self.theme = theme
self.title = title
self.suffix = suffix
self.icon = icon
}
static func ==(lhs: ProfileLevelRatingBarBadge, rhs: ProfileLevelRatingBarBadge) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.suffix != rhs.suffix {
return false
}
if lhs.icon != rhs.icon {
return false
}
return true
}
final class View: UIView {
private let badgeView: UIView
private let badgeMaskView: UIView
private let badgeShapeView: UIImageView
private let badgeShapeAnimation = ComponentView<Empty>()
private let badgeForeground: SimpleLayer
private let badgeIcon: UIImageView
private var disappearingBadgeIcon: UIImageView?
private let badgeLabel = ComponentView<Empty>()
private let suffixLabel = ComponentView<Empty>()
private var badgeTailPosition: CGFloat = 0.0
private var badgeShapeArguments: (Double, Double, CGSize, CGFloat, CGFloat)?
private var component: ProfileLevelRatingBarBadge?
private var isUpdating: Bool = false
private var previousAvailableSize: CGSize?
override init(frame: CGRect) {
self.badgeView = UIView()
self.badgeView.alpha = 0.0
self.badgeView.layer.anchorPoint = CGPoint()
self.badgeShapeView = UIImageView()
self.badgeMaskView = UIView()
self.badgeMaskView.addSubview(self.badgeShapeView)
self.badgeView.mask = self.badgeMaskView
self.badgeForeground = SimpleLayer()
self.badgeForeground.anchorPoint = CGPoint()
self.badgeIcon = UIImageView()
self.badgeIcon.contentMode = .center
super.init(frame: frame)
self.addSubview(self.badgeView)
self.badgeView.layer.addSublayer(self.badgeForeground)
self.badgeView.addSubview(self.badgeIcon)
self.isUserInteractionEnabled = false
}
required init(coder: NSCoder) {
preconditionFailure()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.badgeView.frame.contains(point) {
return self
} else {
return nil
}
}
func update(component: ProfileLevelRatingBarBadge, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
var labelsTransition = transition
if let hint = transition.userData(TransitionHint.self), hint.animateText {
labelsTransition = .spring(duration: 0.5)
}
let previousComponent = self.component
if previousComponent == nil || (previousComponent?.title == "") != (component.title == "") {
if !labelsTransition.animation.isImmediate, self.badgeIcon.image != nil {
if let disappearingBadgeIcon = self.disappearingBadgeIcon {
self.disappearingBadgeIcon = nil
disappearingBadgeIcon.removeFromSuperview()
}
let disappearingBadgeIcon = UIImageView()
disappearingBadgeIcon.contentMode = self.badgeIcon.contentMode
disappearingBadgeIcon.frame = self.badgeIcon.frame
disappearingBadgeIcon.image = self.badgeIcon.image
disappearingBadgeIcon.tintColor = self.badgeIcon.tintColor
self.badgeView.insertSubview(disappearingBadgeIcon, aboveSubview: self.badgeIcon)
labelsTransition.setScale(view: disappearingBadgeIcon, scale: 0.001)
labelsTransition.setAlpha(view: disappearingBadgeIcon, alpha: 0.0, completion: { [weak self, weak disappearingBadgeIcon] _ in
guard let self, let disappearingBadgeIcon else {
return
}
disappearingBadgeIcon.removeFromSuperview()
if self.disappearingBadgeIcon === disappearingBadgeIcon {
self.disappearingBadgeIcon = nil
}
})
labelsTransition.animateScale(view: self.badgeIcon, from: 0.001, to: 1.0)
labelsTransition.animateAlpha(view: self.badgeIcon, from: 0.0, to: 1.0)
}
switch component.icon {
case .rating:
if component.title.isEmpty {
self.badgeIcon.image = UIImage(bundleImageName: "Peer Info/ProfileLevelWarningIcon")?.withRenderingMode(.alwaysTemplate)
} else {
self.badgeIcon.image = UIImage(bundleImageName: "Peer Info/ProfileLevelProgressIcon")?.withRenderingMode(.alwaysTemplate)
}
case .stars:
self.badgeIcon.image = UIImage(bundleImageName: "Premium/SendStarsStarSliderIcon")?.withRenderingMode(.alwaysTemplate)
}
}
self.component = component
self.badgeIcon.tintColor = component.theme.list.itemCheckColors.foregroundColor
self.disappearingBadgeIcon?.tintColor = component.theme.list.itemCheckColors.foregroundColor
let badgeLabelSize = self.badgeLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: Font.with(size: 24.0, design: .round, weight: .semibold, traits: []),
color: component.theme.list.itemCheckColors.foregroundColor,
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.title)
)]
)),
environment: {},
containerSize: CGSize(width: 300.0, height: 100.0)
)
let badgeSuffixSpacing: CGFloat = 0.0
let badgeSuffixSize = self.suffixLabel.update(
transition: labelsTransition,
component: AnyComponent(AnimatedTextComponent(
font: Font.regular(22.0),
color: component.theme.list.itemCheckColors.foregroundColor.withMultipliedAlpha(0.6),
items: [AnimatedTextComponent.Item(
id: AnyHashable(0),
content: .text(component.suffix ?? "")
)]
)),
environment: {},
containerSize: CGSize(width: 300.0, height: 100.0)
)
var badgeWidth: CGFloat = 0.0
if !component.title.isEmpty {
badgeWidth += badgeLabelSize.width + 3.0 + 60.0
} else {
badgeWidth += 78.0
}
if component.suffix != nil {
badgeWidth += badgeSuffixSize.width + badgeSuffixSpacing
}
let badgeSize = CGSize(width: badgeWidth, height: 48.0)
let badgeFullSize = CGSize(width: badgeWidth, height: 48.0 + 12.0)
self.badgeMaskView.frame = CGRect(origin: .zero, size: badgeFullSize)
self.badgeView.bounds = CGRect(origin: .zero, size: badgeFullSize)
self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 600.0, height: badgeFullSize.height + 10.0))
let badgeIconFrame: CGRect
if !component.title.isEmpty {
badgeIconFrame = CGRect(x: 13.0, y: 8.0, width: 30.0, height: 30.0)
} else {
badgeIconFrame = CGRect(x: floor((badgeWidth - 30.0) * 0.5), y: 8.0, width: 30.0, height: 30.0)
}
labelsTransition.setFrame(view: self.badgeIcon, frame: badgeIconFrame)
if let disappearingBadgeIcon = self.disappearingBadgeIcon {
labelsTransition.setFrame(view: disappearingBadgeIcon, frame: badgeIconFrame)
}
self.badgeView.alpha = 1.0
let size = badgeSize
var badgeContentWidth: CGFloat = badgeLabelSize.width
if component.suffix != nil {
badgeContentWidth += badgeSuffixSpacing + badgeSuffixSize.width
}
let badgeLabelFrame = CGRect(origin: CGPoint(x: 15.0 + floorToScreenPixels((badgeFullSize.width - badgeContentWidth) / 2.0), y: 9.0), size: badgeLabelSize)
if let badgeLabelView = self.badgeLabel.view {
if badgeLabelView.superview == nil {
self.badgeView.addSubview(badgeLabelView)
}
labelsTransition.setFrame(view: badgeLabelView, frame: badgeLabelFrame)
}
if let suffixLabelView = self.suffixLabel.view {
if suffixLabelView.superview == nil {
suffixLabelView.layer.anchorPoint = CGPoint()
self.badgeView.addSubview(suffixLabelView)
}
let badgeSuffixFrame = CGRect(origin: CGPoint(x: badgeLabelFrame.maxX + badgeSuffixSpacing, y: badgeLabelFrame.maxY - badgeSuffixSize.height), size: badgeSuffixSize)
labelsTransition.setPosition(view: suffixLabelView, position: badgeSuffixFrame.origin)
suffixLabelView.bounds = CGRect(origin: CGPoint(), size: badgeSuffixFrame.size)
}
if self.previousAvailableSize != availableSize {
self.previousAvailableSize = availableSize
}
return size
}
func updateColors(background: UIColor) {
self.badgeForeground.backgroundColor = background.cgColor
}
func adjustTail(size: CGSize, tailOffset: CGFloat, transition: ComponentTransition) {
if self.badgeShapeView.image == nil {
self.badgeShapeView.image = generateStretchableFilledCircleImage(diameter: 48.0, color: UIColor.white)
}
self.badgeShapeView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: 48.0))
let badgeShapeSize = CGSize(width: 78, height: 60)
let _ = self.badgeShapeAnimation.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "badge_with_tail"),
color: .white,
placeholderColor: nil,
startingPosition: .begin,
size: badgeShapeSize,
renderingScale: UIScreenScale,
loop: false,
playOnce: nil
)),
environment: {},
containerSize: badgeShapeSize
)
if let badgeShapeAnimationView = self.badgeShapeAnimation.view as? LottieComponent.View {
if badgeShapeAnimationView.superview == nil {
badgeShapeAnimationView.layer.anchorPoint = CGPoint()
self.badgeMaskView.addSubview(badgeShapeAnimationView)
}
var shapeFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: badgeShapeSize)
let badgeShapeWidth = badgeShapeSize.width
let midFrame = 359 / 2
if tailOffset < badgeShapeWidth * 0.5 {
let frameIndex = Int(floor(CGFloat(midFrame) * tailOffset / (badgeShapeWidth * 0.5)))
badgeShapeAnimationView.setFrameIndex(index: frameIndex)
} else if tailOffset >= size.width - badgeShapeWidth * 0.5 {
let endOffset = tailOffset - (size.width - badgeShapeWidth * 0.5)
let frameIndex = midFrame + Int(floor(CGFloat(359 - midFrame) * endOffset / (badgeShapeWidth * 0.5)))
badgeShapeAnimationView.setFrameIndex(index: frameIndex)
shapeFrame.origin.x = size.width - badgeShapeWidth
} else {
badgeShapeAnimationView.setFrameIndex(index: midFrame)
shapeFrame.origin.x = tailOffset - badgeShapeWidth * 0.5
}
badgeShapeAnimationView.center = shapeFrame.origin
badgeShapeAnimationView.bounds = CGRect(origin: CGPoint(), size: shapeFrame.size)
}
//let transition: ContainedViewLayoutTransition = .immediate
//transition.updateAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: tailPositionFraction, y: 1.0))
}
func updateBadgeAngle(angle: CGFloat) {
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformRotation(view: self.badgeView, angle: angle)
}
}
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,758 @@
import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import MultilineTextComponent
import BundleIconComponent
import HierarchyTrackingLayer
public final class ProfileLevelRatingBarComponent: Component {
public final class TransitionHint {
public let animate: Bool
public init(animate: Bool) {
self.animate = animate
}
}
public enum Icon {
case rating
case stars
}
let theme: PresentationTheme
let value: CGFloat
let leftLabel: String
let rightLabel: String
let badgeValue: String
let badgeTotal: String?
let level: Int
let icon: Icon
let inversed: Bool
public init(
theme: PresentationTheme,
value: CGFloat,
leftLabel: String,
rightLabel: String,
badgeValue: String,
badgeTotal: String?,
level: Int,
icon: Icon,
inversed: Bool = false
) {
self.theme = theme
self.value = value
self.leftLabel = leftLabel
self.rightLabel = rightLabel
self.badgeValue = badgeValue
self.badgeTotal = badgeTotal
self.level = level
self.icon = icon
self.inversed = inversed
}
public static func ==(lhs: ProfileLevelRatingBarComponent, rhs: ProfileLevelRatingBarComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.leftLabel != rhs.leftLabel {
return false
}
if lhs.rightLabel != rhs.rightLabel {
return false
}
if lhs.badgeValue != rhs.badgeValue {
return false
}
if lhs.badgeTotal != rhs.badgeTotal {
return false
}
if lhs.level != rhs.level {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.inversed != rhs.inversed {
return false
}
return true
}
private final class AnimationState {
enum Wraparound {
case left
case right
}
let fromLevel: Int
let toLevel: Int
let fromLeftLabelText: String
let fromRightLabelText: String
let fromValue: CGFloat
let toValue: CGFloat
let fromBadgeSize: CGSize
let startTime: Double
let duration: Double
let wraparound: Wraparound?
init(fromLevel: Int, toLevel: Int, fromLeftLabelText: String, fromRightLabelText: String, fromValue: CGFloat, toValue: CGFloat, fromBadgeSize: CGSize, startTime: Double, duration: Double, wraparound: Wraparound?) {
self.fromLevel = fromLevel
self.toLevel = toLevel
self.fromLeftLabelText = fromLeftLabelText
self.fromRightLabelText = fromRightLabelText
self.fromValue = fromValue
self.toValue = toValue
self.fromBadgeSize = fromBadgeSize
self.startTime = startTime
self.duration = duration
self.wraparound = wraparound
}
func timeFraction(at timestamp: Double) -> CGFloat {
var fraction = CGFloat((timestamp - self.startTime) / self.duration)
fraction = max(0.0, min(1.0, fraction))
return fraction
}
func stepFraction(at timestamp: Double) -> (step: Int, fraction: CGFloat) {
if self.wraparound != nil {
var t = self.timeFraction(at: timestamp)
t = bezierPoint(0.6, 0.0, 0.4, 1.0, t)
if t < 0.5 {
let vt = t / 0.5
return (0, vt)
} else {
let vt = (t - 0.5) / 0.5
return (1, vt)
}
} else {
let t = self.timeFraction(at: timestamp)
return (0, listViewAnimationCurveSystem(t))
}
}
func fraction(at timestamp: Double) -> CGFloat {
let t = self.timeFraction(at: timestamp)
if self.wraparound != nil {
return listViewAnimationCurveEaseInOut(t)
} else {
return listViewAnimationCurveSystem(t)
}
}
func value(at timestamp: Double) -> CGFloat {
let fraction = self.fraction(at: timestamp)
return (1.0 - fraction) * self.fromValue + fraction * self.toValue
}
func wrapAroundValue(at timestamp: Double, bottomValue: CGFloat, topValue: CGFloat) -> CGFloat {
let (step, fraction) = self.stepFraction(at: timestamp)
if step == 0 {
return (1.0 - fraction) * self.fromValue + fraction * topValue
} else {
return (1.0 - fraction) * bottomValue + fraction * self.toValue
}
}
func badgeSize(at timestamp: Double, endValue: CGSize) -> CGSize {
let fraction = self.fraction(at: timestamp)
return CGSize(
width: (1.0 - fraction) * self.fromBadgeSize.width + fraction * endValue.width,
height: endValue.height
)
}
}
public final class View: UIView {
private let barBackground: UIImageView
private let backgroundClippingContainer: UIView
private let foregroundBarClippingContainer: UIView
private let foregroundClippingContainer: UIView
private let barForeground: UIImageView
private let backgroundLeftLabel = ComponentView<Empty>()
private let backgroundRightLabel = ComponentView<Empty>()
private let foregroundLeftLabel = ComponentView<Empty>()
private let foregroundRightLabel = ComponentView<Empty>()
private let badge = ComponentView<Empty>()
private var component: ProfileLevelRatingBarComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var hierarchyTracker: HierarchyTrackingLayer?
private var animationLink: SharedDisplayLinkDriver.Link?
private var badgePhysicsLink: SharedDisplayLinkDriver.Link?
private var animationState: AnimationState?
private var previousAnimationTimestamp: Double?
private var previousAnimationTimeFraction: CGFloat?
private var animationDeltaTime: Double?
private var animationIsMovingOverStep: Bool = false
private var badgeAngularSpeed: CGFloat = 0.0
private var badgeScale: CGFloat = 1.0
private var badgeAngle: CGFloat = 0.0
private var previousPhysicsTimestamp: Double?
private var testFraction: CGFloat?
private var startTestFraction: CGFloat?
override init(frame: CGRect) {
self.barBackground = UIImageView()
self.backgroundClippingContainer = UIView()
self.backgroundClippingContainer.clipsToBounds = true
self.foregroundBarClippingContainer = UIView()
self.foregroundBarClippingContainer.clipsToBounds = true
self.foregroundClippingContainer = UIView()
self.foregroundClippingContainer.clipsToBounds = true
self.barForeground = UIImageView()
super.init(frame: frame)
let hierarchyTracker = HierarchyTrackingLayer()
self.hierarchyTracker = hierarchyTracker
self.layer.addSublayer(hierarchyTracker)
self.hierarchyTracker?.isInHierarchyUpdated = { [weak self] value in
guard let self else {
return
}
self.updateAnimations()
if value {
if self.badgePhysicsLink == nil {
let badgePhysicsLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
guard let self else {
return
}
self.updateBadgePhysics()
})
self.badgePhysicsLink = badgePhysicsLink
}
} else {
if let badgePhysicsLink = self.badgePhysicsLink {
self.badgePhysicsLink = nil
badgePhysicsLink.invalidate()
}
}
}
#if DEBUG
self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.onPanGesture(_:))))
#endif
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
@objc private func onPanGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
if self.testFraction == nil {
self.testFraction = self.component?.value
}
if self.startTestFraction == nil {
if let testFraction = self.testFraction {
self.startTestFraction = testFraction
}
}
if let startTestFraction = self.startTestFraction {
let x = recognizer.translation(in: self).x
var value: CGFloat = startTestFraction + x / self.bounds.width
value = max(0.0, min(1.0, value))
self.testFraction = value
self.state?.updated(transition: .immediate, isLocal: true)
}
case .ended, .cancelled:
self.startTestFraction = nil
default:
break
}
}
private func updateAnimations() {
let timestamp = CACurrentMediaTime()
let deltaTime: CGFloat
if let previousAnimationTimestamp = self.previousAnimationTimestamp {
deltaTime = min(0.2, timestamp - previousAnimationTimestamp)
} else {
deltaTime = 1.0 / 60.0
}
if let hierarchyTracker = self.hierarchyTracker, hierarchyTracker.isInHierarchy {
if self.animationState != nil {
if self.animationLink == nil {
self.animationLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
guard let self else {
return
}
self.updateAnimations()
})
}
} else {
self.animationLink?.invalidate()
self.animationLink = nil
self.animationState = nil
}
} else {
self.animationLink?.invalidate()
self.animationLink = nil
self.animationState = nil
}
if let animationState = self.animationState {
let timeFraction = animationState.timeFraction(at: timestamp)
if timeFraction >= 1.0 {
self.animationState = nil
self.updateAnimations()
return
} else {
if let previousAnimationTimeFraction = self.previousAnimationTimeFraction {
if previousAnimationTimeFraction < 0.5 && timeFraction >= 0.5 {
self.animationIsMovingOverStep = true
}
}
self.previousAnimationTimeFraction = timeFraction
}
} else {
self.previousAnimationTimeFraction = nil
}
self.animationDeltaTime = Double(deltaTime)
if self.animationState != nil && !self.isUpdating {
self.state?.updated(transition: .immediate, isLocal: true)
}
self.animationDeltaTime = nil
self.animationIsMovingOverStep = false
}
private func addBadgeDeltaX(value: CGFloat, deltaTime: CGFloat) {
var deltaTime = deltaTime
deltaTime /= UIView.animationDurationFactor()
let horizontalVelocity = value / deltaTime
var badgeAngle = self.badgeAngle
badgeAngle -= horizontalVelocity * 0.00005
let maxAngle: CGFloat = 0.1
if abs(badgeAngle) > maxAngle {
badgeAngle = badgeAngle < 0.0 ? -maxAngle : maxAngle
}
self.badgeAngle = badgeAngle
}
private func updateBadgePhysics() {
let timestamp = CACurrentMediaTime()
var deltaTime: CGFloat
if let previousPhysicsTimestamp = self.previousPhysicsTimestamp {
deltaTime = CGFloat(min(1.0 / 60.0, timestamp - previousPhysicsTimestamp))
} else {
deltaTime = CGFloat(1.0 / 60.0)
}
self.previousPhysicsTimestamp = timestamp
deltaTime /= UIView.animationDurationFactor()
let testSpringFriction: CGFloat = 18.5
let testSpringConstant: CGFloat = 243.0
let frictionConstant: CGFloat = testSpringFriction
let springConstant: CGFloat = testSpringConstant
let time: CGFloat = deltaTime
var badgeAngle = self.badgeAngle
// friction force = velocity * friction constant
let frictionForce = self.badgeAngularSpeed * frictionConstant
// spring force = (target point - current position) * spring constant
let springForce = -badgeAngle * springConstant
// force = spring force - friction force
let force = springForce - frictionForce
// velocity = current velocity + force * time / mass
self.badgeAngularSpeed = self.badgeAngularSpeed + force * time
// position = current position + velocity * time
badgeAngle = badgeAngle + self.badgeAngularSpeed * time
badgeAngle = badgeAngle.isNaN ? 0.0 : badgeAngle
let epsilon: CGFloat = 0.01
if abs(badgeAngle) < epsilon && abs(self.badgeAngularSpeed) < epsilon {
badgeAngle = 0.0
self.badgeAngularSpeed = 0.0
}
if abs(badgeAngle) > 0.22 {
badgeAngle = badgeAngle < 0.0 ? -0.22 : 0.22
}
if self.badgeAngle != badgeAngle {
self.badgeAngle = badgeAngle
self.updateBadgeTransform()
}
}
private func updateBadgeTransform() {
guard let badgeView = self.badge.view else {
return
}
var transform = CATransform3DIdentity
transform = CATransform3DScale(transform, self.badgeScale, self.badgeScale, 1.0)
transform = CATransform3DRotate(transform, self.badgeAngle, 0.0, 0.0, 1.0)
badgeView.layer.transform = transform
}
func update(component: ProfileLevelRatingBarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let barHeight: CGFloat = 30.0
self.isUpdating = true
defer {
self.isUpdating = false
}
var labelsTransition = transition
if let previousComponent = self.component, let hint = transition.userData(TransitionHint.self), hint.animate {
labelsTransition = .spring(duration: 0.5)
var fromLevel = previousComponent.level
var fromLeftLabelText = previousComponent.leftLabel
var fromRightLabelText = previousComponent.rightLabel
let toLevel: Int = component.level
let fromValue: CGFloat
if let animationState = self.animationState {
if let wraparound = animationState.wraparound {
let wraparoundEnd: CGFloat
switch wraparound {
case .left:
wraparoundEnd = 0.0
case .right:
wraparoundEnd = 1.0
}
if animationState.stepFraction(at: CACurrentMediaTime()).step == 0 {
fromLevel = animationState.fromLevel
fromLeftLabelText = animationState.fromLeftLabelText
fromRightLabelText = animationState.fromRightLabelText
}
fromValue = animationState.wrapAroundValue(at: CACurrentMediaTime(), bottomValue: 1.0 - wraparoundEnd, topValue: wraparoundEnd)
} else {
fromValue = animationState.value(at: CACurrentMediaTime())
}
} else {
fromValue = previousComponent.value
}
let fromBadgeSize: CGSize
if let badgeView = self.badge.view as? ProfileLevelRatingBarBadge.View {
fromBadgeSize = badgeView.bounds.size
} else {
fromBadgeSize = CGSize()
}
var wraparound: AnimationState.Wraparound?
var duration = 0.4
if previousComponent.level != component.level {
wraparound = component.level > previousComponent.level ? .right : .left
duration = 0.8
}
self.animationState = AnimationState(
fromLevel: fromLevel,
toLevel: toLevel,
fromLeftLabelText: fromLeftLabelText,
fromRightLabelText: fromRightLabelText,
fromValue: fromValue,
toValue: component.value,
fromBadgeSize: fromBadgeSize,
startTime: CACurrentMediaTime(),
duration: duration * UIView.animationDurationFactor(),
wraparound: wraparound
)
self.updateAnimations()
}
self.component = component
self.state = state
if self.barBackground.image == nil {
self.barBackground.image = generateStretchableFilledCircleImage(diameter: 12.0, color: .white)?.withRenderingMode(.alwaysTemplate)
self.barForeground.image = self.barBackground.image
}
if self.barBackground.superview == nil {
self.addSubview(self.barBackground)
self.addSubview(self.backgroundClippingContainer)
self.addSubview(self.foregroundBarClippingContainer)
self.foregroundBarClippingContainer.addSubview(self.barForeground)
self.addSubview(self.foregroundClippingContainer)
}
let progressValue: CGFloat
if let testFraction = self.testFraction {
progressValue = testFraction
} else {
progressValue = component.value
}
let barBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - barHeight), size: CGSize(width: availableSize.width, height: barHeight))
transition.setFrame(view: self.barBackground, frame: barBackgroundFrame)
let barForegroundOriginX = barBackgroundFrame.minX
let barForegroundWidth = floorToScreenPixels(progressValue * barBackgroundFrame.width)
var barForegroundFrame = CGRect(origin: CGPoint(x: barForegroundOriginX, y: barBackgroundFrame.minY), size: CGSize(width: barForegroundWidth, height: barBackgroundFrame.height))
var foregroundAlpha: CGFloat = 1.0
var foregroundContentsAlpha: CGFloat = 1.0
var badgeScale: CGFloat = 1.0
var currentIsNegativeRating: Bool = component.level < 0
var leftLabelText = component.leftLabel
var rightLabelText = component.rightLabel
if let animationState = self.animationState {
if let wraparound = animationState.wraparound {
let (step, progress) = animationState.stepFraction(at: CACurrentMediaTime())
if step == 0 {
currentIsNegativeRating = animationState.fromLevel < 0
leftLabelText = animationState.fromLeftLabelText
rightLabelText = animationState.fromRightLabelText
} else {
currentIsNegativeRating = animationState.toLevel < 0
}
let wraparoundEnd: CGFloat
switch wraparound {
case .left:
wraparoundEnd = 0.0
if step == 0 {
foregroundContentsAlpha = 1.0 * (1.0 - progress)
badgeScale = 1.0 * (1.0 - progress) + 0.3 * progress
} else {
foregroundAlpha = 1.0 * progress
foregroundContentsAlpha = foregroundAlpha
badgeScale = 1.0 * progress + 0.3 * (1.0 - progress)
}
case .right:
wraparoundEnd = 1.0
if step == 0 {
foregroundAlpha = 1.0 * (1.0 - progress)
foregroundContentsAlpha = foregroundAlpha
badgeScale = 1.0 * (1.0 - progress) + 0.3 * progress
} else {
foregroundContentsAlpha = 1.0 * progress
badgeScale = 1.0 * progress + 0.3 * (1.0 - progress)
}
}
let progressValue = animationState.wrapAroundValue(at: CACurrentMediaTime(), bottomValue: 1.0 - wraparoundEnd, topValue: wraparoundEnd)
barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
} else {
let progressValue = animationState.value(at: CACurrentMediaTime())
barForegroundFrame = CGRect(origin: barBackgroundFrame.origin, size: CGSize(width: floorToScreenPixels(progressValue * barBackgroundFrame.width), height: barBackgroundFrame.height))
}
}
let badgeColor: UIColor
if currentIsNegativeRating {
badgeColor = UIColor(rgb: 0xFF3B30)
} else {
badgeColor = component.theme.list.itemCheckColors.fillColor
}
self.barBackground.tintColor = component.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5)
self.barForeground.tintColor = badgeColor
var effectiveBarForegroundFrame = barForegroundFrame
if currentIsNegativeRating || component.inversed {
effectiveBarForegroundFrame.size.width = barBackgroundFrame.maxX - barForegroundFrame.maxX
effectiveBarForegroundFrame.origin.x = barBackgroundFrame.maxX - effectiveBarForegroundFrame.width
}
transition.setPosition(view: self.foregroundBarClippingContainer, position: effectiveBarForegroundFrame.center)
transition.setBounds(view: self.foregroundBarClippingContainer, bounds: CGRect(origin: CGPoint(x: effectiveBarForegroundFrame.minX - barForegroundFrame.minX, y: 0.0), size: effectiveBarForegroundFrame.size))
transition.setPosition(view: self.foregroundClippingContainer, position: effectiveBarForegroundFrame.center)
transition.setBounds(view: self.foregroundClippingContainer, bounds: CGRect(origin: CGPoint(x: effectiveBarForegroundFrame.minX - barForegroundFrame.minX, y: 0.0), size: effectiveBarForegroundFrame.size))
transition.setAlpha(view: self.foregroundBarClippingContainer, alpha: foregroundAlpha)
transition.setAlpha(view: self.foregroundClippingContainer, alpha: foregroundContentsAlpha)
let backgroundClippingFrame: CGRect
if currentIsNegativeRating || component.inversed {
backgroundClippingFrame = CGRect(
x: barBackgroundFrame.minX,
y: barBackgroundFrame.minY,
width: max(0.0, effectiveBarForegroundFrame.minX - barBackgroundFrame.minX),
height: barBackgroundFrame.height
)
} else {
backgroundClippingFrame = CGRect(
x: effectiveBarForegroundFrame.maxX,
y: barBackgroundFrame.minY,
width: max(0, barBackgroundFrame.maxX - effectiveBarForegroundFrame.maxX),
height: barBackgroundFrame.height
)
}
transition.setPosition(view: self.backgroundClippingContainer, position: backgroundClippingFrame.center)
transition.setBounds(view: self.backgroundClippingContainer, bounds: CGRect(origin: CGPoint(x: backgroundClippingFrame.minX - barBackgroundFrame.minX, y: 0.0), size: backgroundClippingFrame.size))
transition.setAlpha(view: self.backgroundClippingContainer, alpha: foregroundContentsAlpha)
transition.setFrame(view: self.barForeground, frame: CGRect(origin: CGPoint(), size: barBackgroundFrame.size))
let labelFont = Font.semibold(14.0)
let leftLabelSize = self.backgroundLeftLabel.update(
transition: labelsTransition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: leftLabelText, font: labelFont, textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let _ = self.foregroundLeftLabel.update(
transition: labelsTransition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: leftLabelText, font: labelFont, textColor: component.theme.list.itemCheckColors.foregroundColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let rightLabelSize = self.backgroundRightLabel.update(
transition: labelsTransition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: rightLabelText, font: labelFont, textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let _ = self.foregroundRightLabel.update(
transition: labelsTransition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: rightLabelText, font: labelFont, textColor: component.theme.list.itemCheckColors.foregroundColor))
)),
environment: {},
containerSize: CGSize(width: barBackgroundFrame.width, height: 100.0)
)
let leftLabelFrame = CGRect(origin: CGPoint(x: 12.0, y: floorToScreenPixels((barBackgroundFrame.height - leftLabelSize.height) * 0.5)), size: leftLabelSize)
let rightLabelFrame = CGRect(origin: CGPoint(x: barBackgroundFrame.width - 12.0 - rightLabelSize.width, y: floorToScreenPixels((barBackgroundFrame.height - rightLabelSize.height) * 0.5)), size: rightLabelSize)
if let backgroundLeftLabelView = self.backgroundLeftLabel.view {
if backgroundLeftLabelView.superview == nil {
backgroundLeftLabelView.layer.anchorPoint = CGPoint()
self.backgroundClippingContainer.addSubview(backgroundLeftLabelView)
}
transition.setPosition(view: backgroundLeftLabelView, position: leftLabelFrame.origin)
backgroundLeftLabelView.bounds = CGRect(origin: CGPoint(), size: leftLabelFrame.size)
}
if let foregroundLeftLabelView = self.foregroundLeftLabel.view {
if foregroundLeftLabelView.superview == nil {
foregroundLeftLabelView.layer.anchorPoint = CGPoint()
self.foregroundClippingContainer.addSubview(foregroundLeftLabelView)
}
transition.setPosition(view: foregroundLeftLabelView, position: leftLabelFrame.origin)
foregroundLeftLabelView.bounds = CGRect(origin: CGPoint(), size: leftLabelFrame.size)
}
if let backgroundRightLabelView = self.backgroundRightLabel.view {
if backgroundRightLabelView.superview == nil {
backgroundRightLabelView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
self.backgroundClippingContainer.addSubview(backgroundRightLabelView)
}
transition.setPosition(view: backgroundRightLabelView, position: CGPoint(x: rightLabelFrame.maxX, y: rightLabelFrame.minY))
backgroundRightLabelView.bounds = CGRect(origin: CGPoint(), size: rightLabelFrame.size)
}
if let foregroundRightLabelView = self.foregroundRightLabel.view {
if foregroundRightLabelView.superview == nil {
foregroundRightLabelView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundClippingContainer.addSubview(foregroundRightLabelView)
}
transition.setPosition(view: foregroundRightLabelView, position: CGPoint(x: rightLabelFrame.maxX, y: rightLabelFrame.minY))
foregroundRightLabelView.bounds = CGRect(origin: CGPoint(), size: rightLabelFrame.size)
}
let icon: ProfileLevelRatingBarBadge.Icon
switch component.icon {
case .rating:
icon = .rating
case .stars:
icon = .stars
}
let badgeSize = self.badge.update(
transition: transition.withUserData(ProfileLevelRatingBarBadge.TransitionHint(animateText: !labelsTransition.animation.isImmediate)),
component: AnyComponent(ProfileLevelRatingBarBadge(
theme: component.theme,
title: component.level < 0 ? "" : "\(component.badgeValue)",
suffix: component.level < 0 ? nil : component.badgeTotal,
icon: icon
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 200.0)
)
if let badgeView = self.badge.view as? ProfileLevelRatingBarBadge.View {
if badgeView.superview == nil {
self.addSubview(badgeView)
}
let apparentBadgeSize: CGSize
if let animationState = self.animationState {
apparentBadgeSize = animationState.badgeSize(at: CACurrentMediaTime(), endValue: badgeSize)
} else {
apparentBadgeSize = badgeSize
}
let badgeOriginX = barBackgroundFrame.minX + barForegroundFrame.width
var badgeFrame = CGRect(origin: CGPoint(x: badgeOriginX - apparentBadgeSize.width * 0.5, y: barBackgroundFrame.minY - 18.0 - badgeSize.height), size: apparentBadgeSize)
let badgeSideInset: CGFloat = 0.0
let badgeOverflowWidth: CGFloat
if badgeFrame.minX < badgeSideInset {
badgeOverflowWidth = badgeSideInset - badgeFrame.minX
} else if badgeFrame.minX + badgeFrame.width > availableSize.width - badgeSideInset {
badgeOverflowWidth = availableSize.width - badgeSideInset - badgeFrame.width - badgeFrame.minX
} else {
badgeOverflowWidth = 0.0
}
badgeFrame.origin.x += badgeOverflowWidth
let badgeTailOffset = (barBackgroundFrame.minX + barForegroundFrame.width) - badgeFrame.minX
let badgePosition = CGPoint(x: badgeFrame.minX + badgeTailOffset, y: badgeFrame.maxY)
if let animationDeltaTime = self.animationDeltaTime, self.animationState != nil, !self.animationIsMovingOverStep {
let previousX = badgeView.center.x
self.addBadgeDeltaX(value: badgePosition.x - previousX, deltaTime: animationDeltaTime)
}
badgeView.center = badgePosition
badgeView.bounds = CGRect(origin: CGPoint(), size: badgeFrame.size)
transition.setAnchorPoint(layer: badgeView.layer, anchorPoint: CGPoint(x: max(0.0, min(1.0, badgeTailOffset / badgeFrame.width)), y: 1.0))
badgeView.updateColors(background: badgeColor)
badgeView.adjustTail(size: apparentBadgeSize, tailOffset: badgeTailOffset, transition: transition)
transition.setAlpha(view: badgeView, alpha: foregroundContentsAlpha)
self.badgeScale = badgeScale
self.updateBadgeTransform()
}
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,31 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "VerifyAlertController",
module_name = "VerifyAlertController",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/AccountContext:AccountContext",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/ComponentFlow",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/TextFieldComponent",
"//submodules/TextFormat",
"//submodules/TelegramUI/Components/PremiumPeerShortcutComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,516 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import ComponentFlow
import MultilineTextComponent
import BalancedTextComponent
import TextFieldComponent
import ComponentDisplayAdapters
import TextFormat
import PremiumPeerShortcutComponent
private final class VerifyAlertContentNode: AlertContentNode {
private let context: AccountContext
private var theme: AlertControllerTheme
private var presentationTheme: PresentationTheme
private let strings: PresentationStrings
private let title: String
private let text: String
private let peer: EnginePeer
private let verifierSettings: BotVerifierSettings
private let verifierIcon: TelegramMediaFile?
private let hasInput: Bool
private let titleView = ComponentView<Empty>()
private let textView = ComponentView<Empty>()
private let shortcut = ComponentView<Empty>()
private let state = ComponentState()
private let inputBackgroundNode = ASImageNode()
private let inputField = ComponentView<Empty>()
private let inputFieldExternalState = TextFieldComponent.ExternalState()
private let inputPlaceholderView = ComponentView<Empty>()
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private let disposable = MetaDisposable()
private var validLayout: CGSize?
private let hapticFeedback = HapticFeedback()
var present: (ViewController) -> () = { _ in }
var complete: (() -> Void)? {
didSet {
// self.inputFieldNode.complete = self.complete
}
}
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
init(context: AccountContext, theme: AlertControllerTheme, presentationTheme: PresentationTheme, strings: PresentationStrings, actions: [TextAlertAction], title: String, text: String, peer: EnginePeer, verifierSettings: BotVerifierSettings, verifierIcon: TelegramMediaFile?, hasInput: Bool) {
self.context = context
self.theme = theme
self.presentationTheme = presentationTheme
self.strings = strings
self.title = title
self.text = text
self.peer = peer
self.verifierSettings = verifierSettings
self.verifierIcon = verifierIcon
self.hasInput = hasInput
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.inputBackgroundNode.displaysAsynchronously = false
self.inputBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 16.0, color: presentationTheme.actionSheet.inputHollowBackgroundColor, strokeColor: presentationTheme.actionSheet.inputBorderColor, strokeWidth: UIScreenPixel)
self.addSubnode(self.actionNodesSeparator)
if self.hasInput {
self.addSubnode(self.inputBackgroundNode)
}
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.updateTheme(theme)
self.state._updated = { [weak self] transition, _ in
guard let self, let _ = self.validLayout else {
return
}
self.requestLayout?(transition.containedViewLayoutTransition)
}
}
deinit {
self.disposable.dispose()
}
var textAndEntities: (String, [MessageTextEntity]) {
let text = self.inputFieldExternalState.text.string
let entities = generateChatInputTextEntities(self.inputFieldExternalState.text)
return (text, entities)
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.theme = theme
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
let measureSize = CGSize(width: size.width - 16.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)
let hadValidLayout = self.validLayout != nil
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 16.0)
let spacing: CGFloat = 5.0
let titleSize = self.titleView.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: self.title, font: Font.semibold(17.0), textColor: self.theme.primaryColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: measureSize.width, height: 1000.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: origin.y), size: titleSize)
if let titleComponentView = self.titleView.view {
if titleComponentView.superview == nil {
self.view.addSubview(titleComponentView)
}
titleComponentView.frame = titleFrame
}
origin.y += titleSize.height + 5.0
let textSize = self.textView.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: self.text, font: Font.regular(13.0), textColor: self.theme.primaryColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: measureSize.width, height: 1000.0)
)
let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: origin.y), size: textSize)
if let textComponentView = self.textView.view {
if textComponentView.superview == nil {
self.view.addSubview(textComponentView)
}
textComponentView.frame = textFrame
}
origin.y += textSize.height + 17.0
let shortcutSize = self.shortcut.update(
transition: .immediate,
component: AnyComponent(PremiumPeerShortcutComponent(
context: self.context,
theme: self.presentationTheme,
peer: self.peer,
icon: self.verifierIcon,
iconPosition: .left
)),
environment: {},
containerSize: CGSize(width: measureSize.width, height: 1000.0)
)
let shortcutFrame = CGRect(origin: CGPoint(x: floor((size.width - shortcutSize.width) * 0.5), y: origin.y), size: shortcutSize)
if let shortcutComponentView = self.shortcut.view {
if shortcutComponentView.superview == nil {
self.view.addSubview(shortcutComponentView)
}
shortcutComponentView.frame = shortcutFrame
}
origin.y += shortcutSize.height + 17.0
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 9.0, right: 18.0)
var contentWidth = max(titleSize.width, minActionsWidth)
contentWidth = max(contentWidth, 234.0)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultWidth = contentWidth + insets.left + insets.right
var resultInputHeight: CGFloat = 0.0
if self.hasInput {
let inputInset: CGFloat = 16.0
let inputWidth = resultWidth - inputInset * 2.0
var characterLimit: Int = 70
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["bot_verification_description_length_limit"] as? Double {
characterLimit = Int(value)
}
let inputFieldSize = self.inputField.update(
transition: .immediate,
component: AnyComponent(TextFieldComponent(
context: self.context,
theme: self.presentationTheme,
strings: self.strings,
externalState: self.inputFieldExternalState,
fontSize: 14.0,
textColor: self.presentationTheme.actionSheet.inputTextColor,
accentColor: self.presentationTheme.actionSheet.controlAccentColor,
insets: UIEdgeInsets(top: 8.0, left: 2.0, bottom: 8.0, right: 2.0),
hideKeyboard: false,
customInputView: nil,
resetText: nil,
isOneLineWhenUnfocused: false,
characterLimit: characterLimit,
emptyLineHandling: .oneConsecutive,
formatMenuAvailability: .none,
returnKeyType: .default,
lockedFormatAction: {
},
present: { [weak self] c in
self?.present(c)
},
paste: { _ in
},
returnKeyAction: nil,
backspaceKeyAction: nil
)),
environment: {},
containerSize: CGSize(width: inputWidth, height: 270.0)
)
self.inputField.parentState = self.state
let inputFieldFrame = CGRect(origin: CGPoint(x: inputInset, y: origin.y), size: inputFieldSize)
if let inputFieldView = self.inputField.view as? TextFieldComponent.View {
if inputFieldView.superview == nil {
self.view.addSubview(inputFieldView)
}
transition.updateFrame(view: inputFieldView, frame: inputFieldFrame)
transition.updateFrame(node: self.inputBackgroundNode, frame: inputFieldFrame)
if !hadValidLayout {
inputFieldView.activateInput()
}
}
let placeholderText = self.verifierSettings.customDescription ?? self.strings.BotVerification_Verify_Placeholder(self.verifierSettings.companyName).string
let inputPlaceholderSize = self.inputPlaceholderView.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(
string: placeholderText,
font: Font.regular(14.0),
textColor: self.presentationTheme.actionSheet.inputPlaceholderColor
)))
),
environment: {},
containerSize: CGSize(width: inputWidth - 32.0, height: 240.0)
)
let inputPlaceholderFrame = CGRect(origin: CGPoint(x: inputInset + 10.0, y: floorToScreenPixels(inputFieldFrame.midY - inputPlaceholderSize.height / 2.0)), size: inputPlaceholderSize)
if let inputPlaceholderView = self.inputPlaceholderView.view {
if inputPlaceholderView.superview == nil {
inputPlaceholderView.isUserInteractionEnabled = false
self.view.addSubview(inputPlaceholderView)
}
inputPlaceholderView.frame = inputPlaceholderFrame
inputPlaceholderView.isHidden = self.inputFieldExternalState.hasText
}
resultInputHeight = inputFieldSize.height + 17.0
}
let resultSize = CGSize(width: resultWidth, height: titleSize.height + textSize.height + shortcutSize.height + spacing + resultInputHeight + 22.0 + actionsHeight + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
return resultSize
}
func deactivateInput() {
if let inputFieldView = self.inputField.view as? TextFieldComponent.View {
inputFieldView.deactivateInput()
}
}
func animateError() {
if let inputFieldView = self.inputField.view as? TextFieldComponent.View {
inputFieldView.layer.addShakeAnimation()
}
self.hapticFeedback.error()
}
}
public func verifyAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, peer: EnginePeer, verifierSettings: BotVerifierSettings, verifierIcon: TelegramMediaFile?, apply: @escaping (String) -> Void) -> AlertController {
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
var dismissImpl: ((Bool) -> Void)?
var applyImpl: (() -> Void)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: presentationData.strings.BotVerification_Verify_Verify, action: {
dismissImpl?(true)
applyImpl?()
})]
let title: String
let text: String
if case let .user(user) = peer {
if let _ = user.botInfo {
title = presentationData.strings.BotVerification_Verify_Bot_Title
text = presentationData.strings.BotVerification_Verify_Bot_Text
} else {
title = presentationData.strings.BotVerification_Verify_User_Title
text = presentationData.strings.BotVerification_Verify_User_Text
}
} else if case let .channel(channel) = peer, case .broadcast = channel.info {
title = presentationData.strings.BotVerification_Verify_Channel_Title
text = presentationData.strings.BotVerification_Verify_Channel_Text
} else {
title = presentationData.strings.BotVerification_Verify_Group_Title
text = presentationData.strings.BotVerification_Verify_Group_Text
}
let contentNode = VerifyAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), presentationTheme: presentationData.theme, strings: presentationData.strings, actions: actions, title: title, text: text, peer: peer, verifierSettings: verifierSettings, verifierIcon: verifierIcon, hasInput: verifierSettings.canModifyDescription)
contentNode.complete = {
applyImpl?()
}
applyImpl = { [weak contentNode] in
guard let contentNode = contentNode else {
return
}
let (text, _) = contentNode.textAndEntities
apply(text)
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
let presentationDataDisposable = (updatedPresentationData?.signal ?? context.sharedContext.presentationData).start(next: { [weak controller] presentationData in
controller?.theme = AlertControllerTheme(presentationData: presentationData)
})
controller.dismissed = { _ in
presentationDataDisposable.dispose()
}
dismissImpl = { [weak controller] animated in
contentNode.deactivateInput()
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
contentNode.present = { [weak controller] c in
controller?.present(c, in: .window(.root))
}
return controller
}
public func removeVerificationAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, peer: EnginePeer, verifierSettings: BotVerifierSettings, verifierIcon: TelegramMediaFile?, completion: @escaping () -> Void) -> AlertController {
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
var dismissImpl: ((Bool) -> Void)?
var applyImpl: (() -> Void)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultDestructiveAction, title: presentationData.strings.BotVerification_Remove_Remove, action: {
dismissImpl?(true)
applyImpl?()
})]
let title = presentationData.strings.BotVerification_Remove_Title
let text: String
if case let .user(user) = peer {
if let _ = user.botInfo {
text = presentationData.strings.BotVerification_Remove_Bot_Text
} else {
text = presentationData.strings.BotVerification_Remove_User_Text
}
} else if case let .channel(channel) = peer, case .broadcast = channel.info {
text = presentationData.strings.BotVerification_Remove_Channel_Text
} else {
text = presentationData.strings.BotVerification_Remove_Group_Text
}
let contentNode = VerifyAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), presentationTheme: presentationData.theme, strings: presentationData.strings, actions: actions, title: title, text: text, peer: peer, verifierSettings: verifierSettings, verifierIcon: verifierIcon, hasInput: false)
applyImpl = {
completion()
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
let presentationDataDisposable = (updatedPresentationData?.signal ?? context.sharedContext.presentationData).start(next: { [weak controller] presentationData in
controller?.theme = AlertControllerTheme(presentationData: presentationData)
})
controller.dismissed = { _ in
presentationDataDisposable.dispose()
}
dismissImpl = { [weak controller] animated in
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
contentNode.present = { [weak controller] c in
controller?.present(c, in: .window(.root))
}
return controller
}