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,21 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "AvatarStoryIndicatorComponent",
module_name = "AvatarStoryIndicatorComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramPresentationData",
"//submodules/Components/HierarchyTrackingLayer",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,939 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import HierarchyTrackingLayer
import TelegramPresentationData
private extension CGFloat {
func remap(fromLow: CGFloat, fromHigh: CGFloat, toLow: CGFloat, toHigh: CGFloat) -> CGFloat {
guard (fromHigh - fromLow) != 0 else {
// Would produce NAN
return 0
}
return toLow + (self - fromLow) * (toHigh - toLow) / (fromHigh - fromLow)
}
}
private extension CGPoint {
/// Returns the length between the receiver and *CGPoint.zero*
var vectorLength: CGFloat {
distanceTo(.zero)
}
var isZero: Bool {
x == 0 && y == 0
}
/// Operator convenience to divide points with /
static func / (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
CGPoint(x: lhs.x / CGFloat(rhs), y: lhs.y / CGFloat(rhs))
}
/// Operator convenience to multiply points with *
static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
CGPoint(x: lhs.x * CGFloat(rhs), y: lhs.y * CGFloat(rhs))
}
/// Operator convenience to add points with +
static func +(left: CGPoint, right: CGPoint) -> CGPoint {
left.add(right)
}
/// Operator convenience to subtract points with -
static func -(left: CGPoint, right: CGPoint) -> CGPoint {
left.subtract(right)
}
/// Returns the distance between the receiver and the given point.
func distanceTo(_ a: CGPoint) -> CGFloat {
let xDist = a.x - x
let yDist = a.y - y
return CGFloat(sqrt((xDist * xDist) + (yDist * yDist)))
}
func rounded(decimal: CGFloat) -> CGPoint {
CGPoint(x: round(decimal * x) / decimal, y: round(decimal * y) / decimal)
}
func interpolate(to: CGPoint, amount: CGFloat) -> CGPoint {
return self + ((to - self) * amount)
}
func interpolate(
_ to: CGPoint,
outTangent: CGPoint,
inTangent: CGPoint,
amount: CGFloat,
maxIterations: Int = 3,
samples: Int = 20,
accuracy: CGFloat = 1)
-> CGPoint
{
if amount == 0 {
return self
}
if amount == 1 {
return to
}
if
colinear(outTangent, inTangent) == true,
outTangent.colinear(inTangent, to) == true
{
return interpolate(to: to, amount: amount)
}
let step = 1 / CGFloat(samples)
var points: [(point: CGPoint, distance: CGFloat)] = [(point: self, distance: 0)]
var totalLength: CGFloat = 0
var previousPoint = self
var previousAmount = CGFloat(0)
var closestPoint = 0
while previousAmount < 1 {
previousAmount = previousAmount + step
if previousAmount < amount {
closestPoint = closestPoint + 1
}
let newPoint = pointOnPath(to, outTangent: outTangent, inTangent: inTangent, amount: previousAmount)
let distance = previousPoint.distanceTo(newPoint)
totalLength = totalLength + distance
points.append((point: newPoint, distance: totalLength))
previousPoint = newPoint
}
let accurateDistance = amount * totalLength
var point = points[closestPoint]
var foundPoint = false
var pointAmount = CGFloat(closestPoint) * step
var nextPointAmount: CGFloat = pointAmount + step
var refineIterations = 0
while foundPoint == false {
refineIterations = refineIterations + 1
/// First see if the next point is still less than the projected length.
let nextPoint = points[min(closestPoint + 1, points.indices.last!)]
if nextPoint.distance < accurateDistance {
point = nextPoint
closestPoint = closestPoint + 1
pointAmount = CGFloat(closestPoint) * step
nextPointAmount = pointAmount + step
if closestPoint == points.count {
foundPoint = true
}
continue
}
if accurateDistance < point.distance {
closestPoint = closestPoint - 1
if closestPoint < 0 {
foundPoint = true
continue
}
point = points[closestPoint]
pointAmount = CGFloat(closestPoint) * step
nextPointAmount = pointAmount + step
continue
}
/// Now we are certain the point is the closest point under the distance
let pointDiff = nextPoint.distance - point.distance
let proposedPointAmount = ((accurateDistance - point.distance) / pointDiff)
.remap(fromLow: 0, fromHigh: 1, toLow: pointAmount, toHigh: nextPointAmount)
let newPoint = pointOnPath(to, outTangent: outTangent, inTangent: inTangent, amount: proposedPointAmount)
let newDistance = point.distance + point.point.distanceTo(newPoint)
pointAmount = proposedPointAmount
point = (point: newPoint, distance: newDistance)
if
accurateDistance - newDistance <= accuracy ||
newDistance - accurateDistance <= accuracy
{
foundPoint = true
}
if refineIterations == maxIterations {
foundPoint = true
}
}
return point.point
}
func pointOnPath(_ to: CGPoint, outTangent: CGPoint, inTangent: CGPoint, amount: CGFloat) -> CGPoint {
let a = interpolate(to: outTangent, amount: amount)
let b = outTangent.interpolate(to: inTangent, amount: amount)
let c = inTangent.interpolate(to: to, amount: amount)
let d = a.interpolate(to: b, amount: amount)
let e = b.interpolate(to: c, amount: amount)
let f = d.interpolate(to: e, amount: amount)
return f
}
func colinear(_ a: CGPoint, _ b: CGPoint) -> Bool {
let area = x * (a.y - b.y) + a.x * (b.y - y) + b.x * (y - a.y);
let accuracy: CGFloat = 0.05
if area < accuracy && area > -accuracy {
return true
}
return false
}
/// Subtracts the given point from the receiving point.
func subtract(_ point: CGPoint) -> CGPoint {
CGPoint(
x: x - point.x,
y: y - point.y)
}
/// Adds the given point from the receiving point.
func add(_ point: CGPoint) -> CGPoint {
CGPoint(
x: x + point.x,
y: y + point.y)
}
}
private extension CurveVertex {
func interpolate(to: CurveVertex, amount: CGFloat) -> CurveVertex {
CurveVertex(
point: point.interpolate(to: to.point, amount: amount),
inTangent: inTangent.interpolate(to: to.inTangent, amount: amount),
outTangent: outTangent.interpolate(to: to.outTangent, amount: amount))
}
}
private struct CurveVertex {
init(_ inTangent: CGPoint, _ point: CGPoint, _ outTangent: CGPoint) {
self.point = point
self.inTangent = inTangent
self.outTangent = outTangent
}
init(point: CGPoint, inTangentRelative: CGPoint, outTangentRelative: CGPoint) {
self.point = point
inTangent = CGPoint(x: point.x + inTangentRelative.x, y: point.y + inTangentRelative.y)
outTangent = CGPoint(x: point.x + outTangentRelative.x, y: point.y + outTangentRelative.y)
}
init(point: CGPoint, inTangent: CGPoint, outTangent: CGPoint) {
self.point = point
self.inTangent = inTangent
self.outTangent = outTangent
}
// MARK: Internal
let point: CGPoint
var inTangent: CGPoint
var outTangent: CGPoint
var inTangentRelative: CGPoint {
return CGPoint(x: inTangent.x - point.x, y: inTangent.y - point.y)
}
var outTangentRelative: CGPoint {
return CGPoint(x: outTangent.x - point.x, y: outTangent.y - point.y)
}
func reversed() -> CurveVertex {
return CurveVertex(point: point, inTangent: outTangent, outTangent: inTangent)
}
func translated(_ translation: CGPoint) -> CurveVertex {
return CurveVertex(point: CGPoint(x: point.x + translation.x, y: point.y + translation.y), inTangent: CGPoint(x: inTangent.x + translation.x, y: inTangent.y + translation.y), outTangent: CGPoint(x: outTangent.x + translation.x, y: outTangent.y + translation.y))
}
/// Trims a path defined by two Vertices at a specific position, from 0 to 1
///
/// The path can be visualized below.
///
/// F is fromVertex.
/// V is the vertex of the receiver.
/// P is the position from 0-1.
/// O is the outTangent of fromVertex.
/// F====O=========P=======I====V
///
/// After trimming the curve can be visualized below.
///
/// S is the returned Start vertex.
/// E is the returned End vertex.
/// T is the trim point.
/// TI and TO are the new tangents for the trimPoint
/// NO and NI are the new tangents for the startPoint and endPoints
/// S==NO=========TI==T==TO=======NI==E
func splitCurve(toVertex: CurveVertex, position: CGFloat) ->
(start: CurveVertex, trimPoint: CurveVertex, end: CurveVertex)
{
/// If position is less than or equal to 0, trim at start.
if position <= 0 {
return (
start: CurveVertex(point: point, inTangentRelative: inTangentRelative, outTangentRelative: .zero),
trimPoint: CurveVertex(point: point, inTangentRelative: .zero, outTangentRelative: outTangentRelative),
end: toVertex)
}
/// If position is greater than or equal to 1, trim at end.
if position >= 1 {
return (
start: self,
trimPoint: CurveVertex(
point: toVertex.point,
inTangentRelative: toVertex.inTangentRelative,
outTangentRelative: .zero),
end: CurveVertex(
point: toVertex.point,
inTangentRelative: .zero,
outTangentRelative: toVertex.outTangentRelative))
}
if outTangentRelative == CGPoint() && toVertex.inTangentRelative == CGPoint() {
/// If both tangents are zero, then span to be trimmed is a straight line.
let trimPoint = point.interpolate(to: toVertex.point, amount: position)
return (
start: self,
trimPoint: CurveVertex(point: trimPoint, inTangentRelative: .zero, outTangentRelative: .zero),
end: toVertex)
}
/// Cutting by amount gives incorrect length....
/// One option is to cut by a stride until it gets close then edge it down.
/// Measuring a percentage of the spans does not equal the same as measuring a percentage of length.
/// This is where the historical trim path bugs come from.
let a = point.interpolate(to: outTangent, amount: position)
let b = outTangent.interpolate(to: toVertex.inTangent, amount: position)
let c = toVertex.inTangent.interpolate(to: toVertex.point, amount: position)
let d = a.interpolate(to: b, amount: position)
let e = b.interpolate(to: c, amount: position)
let f = d.interpolate(to: e, amount: position)
return (
start: CurveVertex(point: point, inTangent: inTangent, outTangent: a),
trimPoint: CurveVertex(point: f, inTangent: d, outTangent: e),
end: CurveVertex(point: toVertex.point, inTangent: c, outTangent: toVertex.outTangent))
}
/// Trims a curve of a known length to a specific length and returns the points.
///
/// There is not a performant yet accurate way to cut a curve to a specific length.
/// This calls splitCurve(toVertex: position:) to split the curve and then measures
/// the length of the new curve. The function then iterates through the samples,
/// adjusting the position of the cut for a more precise cut.
/// Usually a single iteration is enough to get within 0.5 points of the desired
/// length.
///
/// This function should probably live in PathElement, since it deals with curve
/// lengths.
func trimCurve(toVertex: CurveVertex, atLength: CGFloat, curveLength: CGFloat, maxSamples: Int, accuracy: CGFloat = 1) ->
(start: CurveVertex, trimPoint: CurveVertex, end: CurveVertex)
{
var currentPosition = atLength / curveLength
var results = splitCurve(toVertex: toVertex, position: currentPosition)
if maxSamples == 0 {
return results
}
for _ in 1...maxSamples {
let length = results.start.distanceTo(results.trimPoint)
let lengthDiff = atLength - length
/// Check if length is correct.
if lengthDiff < accuracy {
return results
}
let diffPosition = max(min((currentPosition / length) * lengthDiff, currentPosition * 0.5), currentPosition * -0.5)
currentPosition = diffPosition + currentPosition
results = splitCurve(toVertex: toVertex, position: currentPosition)
}
return results
}
/// The distance from the receiver to the provided vertex.
///
/// For lines (zeroed tangents) the distance between the two points is measured.
/// For curves the curve is iterated over by sample count and the points are measured.
/// This is ~99% accurate at a sample count of 30
func distanceTo(_ toVertex: CurveVertex, sampleCount: Int = 25) -> CGFloat {
if outTangentRelative.isZero && toVertex.inTangentRelative.isZero {
/// Return a linear distance.
return point.distanceTo(toVertex.point)
}
var distance: CGFloat = 0
var previousPoint = point
for i in 0..<sampleCount {
let pointOnCurve = splitCurve(toVertex: toVertex, position: CGFloat(i) / CGFloat(sampleCount)).trimPoint
distance = distance + previousPoint.distanceTo(pointOnCurve.point)
previousPoint = pointOnCurve.point
}
distance = distance + previousPoint.distanceTo(toVertex.point)
return distance
}
}
public final class AvatarStoryIndicatorComponent: Component {
public struct Colors: Equatable {
public var unseenColors: [UIColor]
public var unseenCloseFriendsColors: [UIColor]
public var seenColors: [UIColor]
public init(
unseenColors: [UIColor],
unseenCloseFriendsColors: [UIColor],
seenColors: [UIColor]
) {
self.unseenColors = unseenColors
self.unseenCloseFriendsColors = unseenCloseFriendsColors
self.seenColors = seenColors
}
public init(theme: PresentationTheme) {
self.unseenColors = [theme.chatList.storyUnseenColors.topColor, theme.chatList.storyUnseenColors.bottomColor]
self.unseenCloseFriendsColors = [theme.chatList.storyUnseenPrivateColors.topColor, theme.chatList.storyUnseenPrivateColors.bottomColor]
self.seenColors = [theme.chatList.storySeenColors.topColor, theme.chatList.storySeenColors.bottomColor]
}
}
public struct Counters: Equatable {
public var totalCount: Int
public var unseenCount: Int
public init(totalCount: Int, unseenCount: Int) {
self.totalCount = totalCount
self.unseenCount = unseenCount
}
}
public enum Progress: Equatable {
case indefinite
case definite(Float)
}
public let hasUnseen: Bool
public let hasUnseenCloseFriendsItems: Bool
public let hasLiveItems: Bool
public let colors: Colors
public let activeLineWidth: CGFloat
public let inactiveLineWidth: CGFloat
public let counters: Counters?
public let progress: Progress?
public let isRoundedRect: Bool
public init(
hasUnseen: Bool,
hasUnseenCloseFriendsItems: Bool,
hasLiveItems: Bool,
colors: Colors,
activeLineWidth: CGFloat,
inactiveLineWidth: CGFloat,
counters: Counters?,
progress: Progress? = nil,
isRoundedRect: Bool = false
) {
self.hasUnseen = hasUnseen
self.hasUnseenCloseFriendsItems = hasUnseenCloseFriendsItems
self.hasLiveItems = hasLiveItems
self.colors = colors
self.activeLineWidth = activeLineWidth
self.inactiveLineWidth = inactiveLineWidth
self.counters = counters
self.progress = progress
self.isRoundedRect = isRoundedRect
}
public static func ==(lhs: AvatarStoryIndicatorComponent, rhs: AvatarStoryIndicatorComponent) -> Bool {
if lhs.hasUnseen != rhs.hasUnseen {
return false
}
if lhs.hasUnseenCloseFriendsItems != rhs.hasUnseenCloseFriendsItems {
return false
}
if lhs.hasLiveItems != rhs.hasLiveItems {
return false
}
if lhs.colors != rhs.colors {
return false
}
if lhs.activeLineWidth != rhs.activeLineWidth {
return false
}
if lhs.inactiveLineWidth != rhs.inactiveLineWidth {
return false
}
if lhs.counters != rhs.counters {
return false
}
if lhs.progress != rhs.progress {
return false
}
if lhs.isRoundedRect != rhs.isRoundedRect {
return false
}
return true
}
private final class ProgressLayer: HierarchyTrackingLayer {
enum Value: Equatable {
case indefinite
case progress(Float)
}
private struct Params: Equatable {
var size: CGSize
var lineWidth: CGFloat
var value: Value
}
private var currentParams: Params?
private let uploadProgressLayer = SimpleShapeLayer()
private let indefiniteDashLayer = SimpleShapeLayer()
private let indefiniteReplicatorLayer = CAReplicatorLayer()
override init() {
super.init()
self.uploadProgressLayer.fillColor = nil
self.uploadProgressLayer.strokeColor = UIColor.white.cgColor
self.uploadProgressLayer.lineCap = .round
self.indefiniteDashLayer.fillColor = nil
self.indefiniteDashLayer.strokeColor = UIColor.white.cgColor
self.indefiniteDashLayer.lineCap = .round
self.indefiniteDashLayer.lineJoin = .round
self.indefiniteDashLayer.strokeEnd = 0.0333
let count = 1.0 / self.indefiniteDashLayer.strokeEnd
let angle = (2.0 * Double.pi) / Double(count)
self.indefiniteReplicatorLayer.addSublayer(self.indefiniteDashLayer)
self.indefiniteReplicatorLayer.instanceCount = Int(count)
self.indefiniteReplicatorLayer.instanceTransform = CATransform3DMakeRotation(CGFloat(angle), 0.0, 0.0, 1.0)
self.indefiniteReplicatorLayer.transform = CATransform3DMakeRotation(-.pi / 2.0, 0.0, 0.0, 1.0)
self.indefiniteReplicatorLayer.instanceDelay = 0.025
self.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.updateAnimations(transition: .immediate)
}
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func reset() {
self.currentParams = nil
self.indefiniteDashLayer.path = nil
self.uploadProgressLayer.path = nil
}
func updateAnimations(transition: ComponentTransition) {
guard let params = self.currentParams else {
return
}
switch params.value {
case let .progress(progress):
if self.indefiniteReplicatorLayer.superlayer != nil {
self.indefiniteReplicatorLayer.removeFromSuperlayer()
}
if self.uploadProgressLayer.superlayer == nil {
self.addSublayer(self.uploadProgressLayer)
}
transition.setShapeLayerStrokeEnd(layer: self.uploadProgressLayer, strokeEnd: CGFloat(progress))
if self.uploadProgressLayer.animation(forKey: "rotation") == nil {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.duration = 2.0
rotationAnimation.fromValue = NSNumber(value: Float(0.0))
rotationAnimation.toValue = NSNumber(value: Float(Double.pi * 2.0))
rotationAnimation.repeatCount = Float.infinity
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
self.uploadProgressLayer.add(rotationAnimation, forKey: "rotation")
}
case .indefinite:
if self.uploadProgressLayer.superlayer == nil {
self.uploadProgressLayer.removeFromSuperlayer()
}
if self.indefiniteReplicatorLayer.superlayer == nil {
self.addSublayer(self.indefiniteReplicatorLayer)
}
if self.indefiniteReplicatorLayer.animation(forKey: "rotation") == nil {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.duration = 4.0
rotationAnimation.fromValue = NSNumber(value: -.pi / 2.0)
rotationAnimation.toValue = NSNumber(value: -.pi / 2.0 + Double.pi * 2.0)
rotationAnimation.repeatCount = Float.infinity
rotationAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
self.indefiniteReplicatorLayer.add(rotationAnimation, forKey: "rotation")
}
if self.indefiniteDashLayer.animation(forKey: "dash") == nil {
let dashAnimation = CAKeyframeAnimation(keyPath: "strokeStart")
dashAnimation.keyTimes = [0.0, 0.45, 0.55, 1.0]
dashAnimation.values = [
self.indefiniteDashLayer.strokeStart,
self.indefiniteDashLayer.strokeEnd,
self.indefiniteDashLayer.strokeEnd,
self.indefiniteDashLayer.strokeStart,
]
dashAnimation.timingFunction = CAMediaTimingFunction(name: .linear)
dashAnimation.duration = 2.5
dashAnimation.repeatCount = .infinity
self.indefiniteDashLayer.add(dashAnimation, forKey: "dash")
}
}
}
func update(size: CGSize, radius: CGFloat, isRoundedRect: Bool, lineWidth: CGFloat, value: Value, transition: ComponentTransition) {
let params = Params(
size: size,
lineWidth: lineWidth,
value: value
)
if self.currentParams == params {
return
}
self.currentParams = params
self.indefiniteDashLayer.lineWidth = lineWidth
self.uploadProgressLayer.lineWidth = lineWidth
let bounds = CGRect(origin: .zero, size: size)
if self.uploadProgressLayer.path == nil {
let path = CGMutablePath()
path.addEllipse(in: CGRect(origin: CGPoint(x: (size.width - radius * 2.0) * 0.5, y: (size.height - radius * 2.0) * 0.5), size: CGSize(width: radius * 2.0, height: radius * 2.0)))
self.uploadProgressLayer.path = path
self.uploadProgressLayer.frame = bounds
}
if self.indefiniteDashLayer.path == nil {
let path = CGMutablePath()
path.addEllipse(in: CGRect(origin: CGPoint(x: (size.width - radius * 2.0) * 0.5, y: (size.height - radius * 2.0) * 0.5), size: CGSize(width: radius * 2.0, height: radius * 2.0)))
self.indefiniteDashLayer.path = path
self.indefiniteReplicatorLayer.frame = bounds
self.indefiniteDashLayer.frame = bounds
}
self.updateAnimations(transition: transition)
}
}
public final class View: UIView {
private let indicatorView: UIImageView
private var progressLayer: ProgressLayer?
private var colorLayer: SimpleGradientLayer?
private var component: AvatarStoryIndicatorComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.indicatorView = UIImageView()
super.init(frame: frame)
self.addSubview(self.indicatorView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: AvatarStoryIndicatorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let diameter: CGFloat
let maxOuterInset = component.activeLineWidth * 2.0
diameter = availableSize.width + maxOuterInset * 2.0
let imageDiameter = ceil(availableSize.width + maxOuterInset * 2.0)
let activeColors: [CGColor]
let inactiveColors: [CGColor]
if component.hasLiveItems {
activeColors = [UIColor(rgb: 0xFF3777).cgColor, UIColor(rgb: 0xFF2D55).cgColor]
} else if component.hasUnseenCloseFriendsItems {
activeColors = component.colors.unseenCloseFriendsColors.map(\.cgColor)
} else {
activeColors = component.colors.unseenColors.map(\.cgColor)
}
inactiveColors = component.colors.seenColors.map(\.cgColor)
let radius = (diameter - component.activeLineWidth) * 0.5
self.indicatorView.image = generateImage(CGSize(width: imageDiameter, height: imageDiameter), rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
context.setLineCap(.round)
var locations: [CGFloat] = [0.0, 1.0]
if let counters = component.counters, !component.hasLiveItems, counters.totalCount > 1 {
if component.isRoundedRect {
let lineWidth: CGFloat = (component.hasUnseen || component.hasLiveItems) ? component.activeLineWidth : component.inactiveLineWidth
context.setLineWidth(lineWidth)
let path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: size.width * 0.5 - diameter * 0.5, y: size.height * 0.5 - diameter * 0.5), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: floor(diameter * 0.27))
var startPoint: CGPoint?
var vertices: [CurveVertex] = []
path.cgPath.applyWithBlock({ element in
switch element.pointee.type {
case .moveToPoint:
startPoint = element.pointee.points[0]
case .addLineToPoint:
if let _ = vertices.last {
vertices.append(CurveVertex(point: element.pointee.points[0], inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
} else if let startPoint {
vertices.append(CurveVertex(point: startPoint, inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
vertices.append(CurveVertex(point: element.pointee.points[0], inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
}
case .addQuadCurveToPoint:
break
case .addCurveToPoint:
if let _ = vertices.last {
vertices.append(CurveVertex(point: element.pointee.points[2], inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
} else if let startPoint {
vertices.append(CurveVertex(point: startPoint, inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
vertices.append(CurveVertex(point: element.pointee.points[2], inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
}
if vertices.count >= 2 {
vertices[vertices.count - 2].outTangent = element.pointee.points[0]
vertices[vertices.count - 1].inTangent = element.pointee.points[1]
}
case .closeSubpath:
if let startPointValue = startPoint {
vertices.append(CurveVertex(point: startPointValue, inTangentRelative: CGPoint(), outTangentRelative: CGPoint()))
startPoint = nil
}
@unknown default:
break
}
})
var length: CGFloat = 0.0
var firstOffset: CGFloat = 0.0
for i in 0 ..< vertices.count - 1 {
let value = vertices[i].distanceTo(vertices[i + 1])
if firstOffset == 0.0 {
firstOffset = value * 0.5
}
length += value
}
let spacing: CGFloat = component.activeLineWidth * 2.0
let useableLength = length - spacing * CGFloat(counters.totalCount)
let segmentLength = useableLength / CGFloat(counters.totalCount)
context.setLineWidth(lineWidth)
for index in 0 ..< counters.totalCount {
var dashWidths: [CGFloat] = []
dashWidths.append(segmentLength)
dashWidths.append(10000000.0)
let colors: [CGColor]
if index >= counters.totalCount - counters.unseenCount {
colors = activeColors
} else {
colors = inactiveColors
}
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.resetClip()
context.setLineDash(phase: -firstOffset - spacing * 0.5 - CGFloat(index) * (spacing + segmentLength), lengths: dashWidths)
context.addPath(path.cgPath)
context.replacePathWithStrokedPath()
context.clip()
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
if index == counters.totalCount - 1 {
context.resetClip()
let addPath = CGMutablePath()
addPath.move(to: CGPoint(x: vertices[0].interpolate(to: vertices[1], amount: 0.5).point.x - spacing * 0.5, y: vertices[0].point.y))
addPath.addLine(to: CGPoint(x: vertices[0].point.x, y: vertices[0].point.y))
context.setLineDash(phase: 0.0, lengths: [])
context.addPath(addPath)
context.replacePathWithStrokedPath()
context.clip()
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
}
}
} else {
let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5)
let spacing: CGFloat = component.activeLineWidth * 2.0
let angularSpacing: CGFloat = spacing / radius
let circleLength = CGFloat.pi * 2.0 * radius
let segmentLength = (circleLength - spacing * CGFloat(counters.totalCount)) / CGFloat(counters.totalCount)
let segmentAngle = segmentLength / radius
for pass in 0 ..< 2 {
context.resetClip()
if pass == 0 {
context.setLineWidth(component.inactiveLineWidth)
} else {
context.setLineWidth(component.activeLineWidth)
}
let startIndex: Int
let endIndex: Int
if pass == 0 {
startIndex = 0
endIndex = counters.totalCount - counters.unseenCount
} else {
startIndex = counters.totalCount - counters.unseenCount
endIndex = counters.totalCount
}
if startIndex < endIndex {
for i in startIndex ..< endIndex {
let startAngle = CGFloat(i) * (angularSpacing + segmentAngle) - CGFloat.pi * 0.5 + angularSpacing * 0.5
context.move(to: CGPoint(x: center.x + cos(startAngle) * radius, y: center.y + sin(startAngle) * radius))
context.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: startAngle + segmentAngle, clockwise: false)
}
context.replacePathWithStrokedPath()
context.clip()
let colors: [CGColor]
if pass == 1 {
colors = activeColors
} else {
colors = inactiveColors
}
let colorSpace = CGColorSpaceCreateDeviceRGB()
if 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())
}
}
}
}
} else {
let lineWidth: CGFloat = (component.hasUnseen || component.hasLiveItems) ? component.activeLineWidth : component.inactiveLineWidth
context.setLineWidth(lineWidth)
if component.isRoundedRect {
let path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: size.width * 0.5 - diameter * 0.5, y: size.height * 0.5 - diameter * 0.5), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: floor(diameter * 0.27))
context.addPath(path.cgPath)
} else {
context.addEllipse(in: CGRect(origin: CGPoint(x: size.width * 0.5 - diameter * 0.5, y: size.height * 0.5 - diameter * 0.5), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
}
context.replacePathWithStrokedPath()
context.clip()
let colors: [CGColor]
if component.hasLiveItems {
colors = activeColors
} else if component.hasUnseen {
colors = activeColors
} else {
colors = inactiveColors
}
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())
}
})
let indicatorFrame = CGRect(origin: CGPoint(x: (availableSize.width - imageDiameter) * 0.5, y: (availableSize.height - imageDiameter) * 0.5), size: CGSize(width: imageDiameter, height: imageDiameter))
transition.setFrame(view: self.indicatorView, frame: indicatorFrame)
let progressTransition = ComponentTransition(animation: .curve(duration: 0.3, curve: .easeInOut))
if let progress = component.progress, !component.isRoundedRect {
let colorLayer: SimpleGradientLayer
if let current = self.colorLayer {
colorLayer = current
} else {
colorLayer = SimpleGradientLayer()
self.colorLayer = colorLayer
self.layer.addSublayer(colorLayer)
colorLayer.opacity = 0.0
}
progressTransition.setAlpha(view: self.indicatorView, alpha: 0.0)
progressTransition.setAlpha(layer: colorLayer, alpha: 1.0)
let colors: [CGColor] = activeColors
let lineWidth: CGFloat
if case .definite = progress {
lineWidth = component.activeLineWidth
} else {
lineWidth = (component.hasUnseen || component.hasLiveItems) ? component.activeLineWidth : component.inactiveLineWidth
}
colorLayer.colors = colors
colorLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
colorLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
let progressLayer: ProgressLayer
if let current = self.progressLayer {
progressLayer = current
} else {
progressLayer = ProgressLayer()
self.progressLayer = progressLayer
colorLayer.mask = progressLayer
}
colorLayer.frame = indicatorFrame
progressLayer.frame = CGRect(origin: CGPoint(), size: indicatorFrame.size)
let mappedProgress: ProgressLayer.Value
switch progress {
case .indefinite:
mappedProgress = .indefinite
case let .definite(value):
mappedProgress = .progress(value)
}
progressLayer.update(size: indicatorFrame.size, radius: radius, isRoundedRect: component.isRoundedRect, lineWidth: lineWidth, value: mappedProgress, transition: .immediate)
} else {
progressTransition.setAlpha(view: self.indicatorView, alpha: 1.0)
self.progressLayer = nil
if let colorLayer = self.colorLayer {
self.colorLayer = nil
progressTransition.setAlpha(layer: colorLayer, alpha: 0.0, completion: { [weak colorLayer] _ in
colorLayer?.removeFromSuperlayer()
})
}
}
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,26 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ForwardInfoPanelComponent",
module_name = "ForwardInfoPanelComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramCore",
"//submodules/TelegramPresentationData",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent",
"//submodules/TelegramUI/Components/Chat/MessageInlineBlockBackgroundView",
"//submodules/AccountContext",
"//submodules/TextFormat",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,208 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import MessageInlineBlockBackgroundView
import AccountContext
import TextFormat
public final class ForwardInfoPanelComponent: Component {
public let context: AccountContext
public let authorName: String
public let text: String
public let entities: [MessageTextEntity]
public let isChannel: Bool
public let isVibrant: Bool
public let fillsWidth: Bool
public init(
context: AccountContext,
authorName: String,
text: String,
entities: [MessageTextEntity],
isChannel: Bool,
isVibrant: Bool,
fillsWidth: Bool
) {
self.context = context
self.authorName = authorName
self.text = text
self.entities = entities
self.isChannel = isChannel
self.isVibrant = isVibrant
self.fillsWidth = fillsWidth
}
public static func ==(lhs: ForwardInfoPanelComponent, rhs: ForwardInfoPanelComponent) -> Bool {
if lhs.authorName != rhs.authorName {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if lhs.isChannel != rhs.isChannel {
return false
}
if lhs.isVibrant != rhs.isVibrant {
return false
}
if lhs.fillsWidth != rhs.fillsWidth {
return false
}
return true
}
public final class View: UIView {
public let backgroundView: UIImageView
private let blurBackgroundView: UIVisualEffectView
private let blockView: MessageInlineBlockBackgroundView
private var iconView: UIImageView?
private var title = ComponentView<Empty>()
private var text = ComponentView<Empty>()
private var component: ForwardInfoPanelComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
if #available(iOS 13.0, *) {
self.blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
} else {
self.blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
}
self.blurBackgroundView.clipsToBounds = true
self.backgroundView = UIImageView()
self.backgroundView.image = generateStretchableFilledCircleImage(radius: 4.0, color: UIColor(white: 0.0, alpha: 0.4))
self.blockView = MessageInlineBlockBackgroundView()
super.init(frame: frame)
self.addSubview(self.blockView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ForwardInfoPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
var titleOffset: CGFloat = 0.0
let sideInset: CGFloat = !component.text.isEmpty ? 9.0 : 6.0
let iconView: UIImageView
if let current = self.iconView {
iconView = current
} else {
iconView = UIImageView(image: UIImage(bundleImageName: component.isChannel ? "Stories/RepostChannel" : "Stories/RepostUser")?.withRenderingMode(.alwaysTemplate))
iconView.alpha = 0.55
iconView.tintColor = .white
self.addSubview(iconView)
self.iconView = iconView
}
if let image = iconView.image {
iconView.frame = CGRect(origin: CGPoint(x: sideInset + UIScreenPixel, y: 5.0), size: image.size)
}
titleOffset += 13.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: component.authorName,
font: Font.semibold(14.0),
textColor: .white
)),
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: availableSize.width - titleOffset - 20.0, height: availableSize.height)
)
let titleFrame = CGRect(origin: CGPoint(x: sideInset + titleOffset, y: 3.0), size: titleSize)
if let view = self.title.view {
if view.superview == nil {
self.addSubview(view)
}
view.frame = titleFrame
}
let textFont = Font.regular(14.0)
let textColor = UIColor.white
let attributedText = stringWithAppliedEntities(component.text, entities: component.entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil)
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff, alpha: 0.4),
text: .plain(attributedText),
horizontalAlignment: .natural,
truncationType: .end,
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 20.0, height: availableSize.height)
)
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: 20.0), size: textSize)
if let view = self.text.view {
if view.superview == nil {
self.addSubview(view)
}
view.frame = textFrame
}
let width: CGFloat
let size: CGSize
if component.fillsWidth {
width = availableSize.width
} else {
width = max(titleFrame.maxX, textFrame.maxX) + sideInset
}
if !component.text.isEmpty {
size = CGSize(width: width, height: 40.0)
let lineColor: UIColor
if !component.isVibrant {
self.blurBackgroundView.frame = CGRect(origin: .zero, size: size)
self.insertSubview(self.blurBackgroundView, at: 0)
lineColor = UIColor(white: 1.0, alpha: 0.55)
} else {
lineColor = UIColor(white: 1.0, alpha: 0.5)
}
self.blurBackgroundView.layer.cornerRadius = 4.0
self.blockView.update(size: size, isTransparent: true, primaryColor: lineColor, secondaryColor: nil, thirdColor: nil, backgroundColor: nil, pattern: nil, animation: .None)
self.blockView.frame = CGRect(origin: .zero, size: size)
} else {
size = CGSize(width: titleFrame.maxX + sideInset, height: 23.0)
if !component.isVibrant {
self.blurBackgroundView.frame = CGRect(origin: .zero, size: size)
self.insertSubview(self.blurBackgroundView, at: 0)
}
self.blurBackgroundView.layer.cornerRadius = size.height / 2.0
}
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,30 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StoryLiveChatMessageComponent",
module_name = "StoryLiveChatMessageComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/Components/BundleIconComponent",
"//submodules/AccountContext",
"//submodules/TelegramCore",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent",
"//submodules/AvatarNode",
"//submodules/TelegramPresentationData",
"//submodules/TelegramUI/Components/StarsParticleEffect",
"//submodules/TelegramUI/Components/PeerNameTextComponent",
"//submodules/TextFormat",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,696 @@
import Foundation
import UIKit
import ComponentFlow
import Display
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import TelegramPresentationData
import TelegramCore
import AvatarNode
import AccountContext
import StarsParticleEffect
import AppBundle
import TextFormat
import PeerNameTextComponent
private func generateStarsAmountImage() -> UIImage {
return UIImage(bundleImageName: "Chat/Message/StarsCount")!.precomposed().withRenderingMode(.alwaysTemplate)
}
public final class StoryLiveChatMessageComponent: Component {
public struct Layout: Equatable {
public var isFlipped: Bool
public var insets: UIEdgeInsets
public var fitToWidth: Bool
public var transparentBackground: Bool
public init(isFlipped: Bool, insets: UIEdgeInsets, fitToWidth: Bool, transparentBackground: Bool) {
self.isFlipped = isFlipped
self.insets = insets
self.fitToWidth = fitToWidth
self.transparentBackground = transparentBackground
}
}
let context: AccountContext
let strings: PresentationStrings
let theme: PresentationTheme
let layout: Layout
let message: GroupCallMessagesContext.Message
let topPlace: Int?
let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?
public init(
context: AccountContext,
strings: PresentationStrings,
theme: PresentationTheme,
layout: Layout,
message: GroupCallMessagesContext.Message,
topPlace: Int?,
contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?
) {
self.context = context
self.strings = strings
self.theme = theme
self.layout = layout
self.message = message
self.topPlace = topPlace
self.contextGesture = contextGesture
}
public static func ==(lhs: StoryLiveChatMessageComponent, rhs: StoryLiveChatMessageComponent) -> Bool {
if lhs === rhs {
return true
}
if lhs.context !== rhs.context {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.layout != rhs.layout {
return false
}
if lhs.message != rhs.message {
return false
}
if lhs.topPlace != rhs.topPlace {
return false
}
return true
}
public final class View: UIView {
private let extractedContainerNode: ContextExtractedContentContainingNode
private let containerNode: ContextControllerSourceNode
private let contentContainer: UIView
private var avatarNode: AvatarNode?
private let textExternal = MultilineTextWithEntitiesComponent.External()
private let authorTitle = ComponentView<Empty>()
private var adminBadgeText: ComponentView<Empty>?
private let text = ComponentView<Empty>()
private var crownIcon: UIImageView?
private var backgroundView: UIImageView?
private var effectLayer: StarsParticleEffectLayer?
private var starsAmountBackgroundView: UIImageView?
private var starsAmountIcon: UIImageView?
private var starsAmountText: ComponentView<Empty>?
private var component: StoryLiveChatMessageComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
static let starsAmountImage: UIImage = generateStarsAmountImage()
override public init(frame: CGRect) {
self.contentContainer = UIView()
self.extractedContainerNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
super.init(frame: frame)
self.addSubview(self.contentContainer)
self.containerNode.addSubnode(self.extractedContainerNode)
self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode
self.contentContainer.addSubview(self.containerNode.view)
self.containerNode.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
return
}
component.contextGesture?(gesture, self.extractedContainerNode)
}
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
public func flashHighlight() {
if let backgroundView = self.backgroundView, backgroundView.alpha != 1.0 {
let initialAlpha = backgroundView.alpha
backgroundView.layer.animateAlpha(from: initialAlpha, to: 1.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
guard let self, let backgroundView = self.backgroundView else {
return
}
backgroundView.layer.animateAlpha(from: 1.0, to: initialAlpha, duration: 0.2, delay: 2.0)
})
}
}
func update(component: StoryLiveChatMessageComponent, 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
self.contentContainer.transform = component.layout.isFlipped ? CGAffineTransformMakeRotation(-CGFloat.pi) : .identity
self.containerNode.isGestureEnabled = component.contextGesture != nil
let insets = component.layout.insets
let avatarSize: CGFloat = 24.0
let avatarSpacing: CGFloat = 6.0
let avatarBackgroundInset: CGFloat = 4.0
let primaryTextColor = UIColor(white: 1.0, alpha: 1.0)
let secondaryTextColor = UIColor(white: 1.0, alpha: 0.8)
var displayStarsAmountBackground = false
var starsAmountTextSize: CGSize?
if let paidStars = component.message.paidStars {
displayStarsAmountBackground = component.message.text.isEmpty
let starsAmountIcon: UIImageView
if let current = self.starsAmountIcon {
starsAmountIcon = current
} else {
starsAmountIcon = UIImageView()
self.starsAmountIcon = starsAmountIcon
self.extractedContainerNode.contentNode.view.addSubview(starsAmountIcon)
starsAmountIcon.image = View.starsAmountImage
}
starsAmountIcon.tintColor = secondaryTextColor
let starsAmountText: ComponentView<Empty>
if let current = self.starsAmountText {
starsAmountText = current
} else {
starsAmountText = ComponentView()
self.starsAmountText = starsAmountText
}
starsAmountTextSize = starsAmountText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "\(paidStars)", font: Font.semibold(11.0), textColor: displayStarsAmountBackground ? primaryTextColor : secondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
} else {
if let starsAmountIcon = self.starsAmountIcon {
self.starsAmountIcon = nil
starsAmountIcon.removeFromSuperview()
}
if let starsAmountText = self.starsAmountText {
self.starsAmountText = nil
starsAmountText.view?.removeFromSuperview()
}
}
if displayStarsAmountBackground, let paidStars = component.message.paidStars, let baseColor = GroupCallMessagesContext.getStarAmountParamMapping(params: LiveChatMessageParams(appConfig: component.context.currentAppConfiguration.with({ $0 })), value: paidStars).color {
let starsAmountBackgroundView: UIImageView
if let current = self.starsAmountBackgroundView {
starsAmountBackgroundView = current
} else {
starsAmountBackgroundView = UIImageView()
starsAmountBackgroundView.image = generateStretchableFilledCircleImage(diameter: 20.0, color: .white)?.withRenderingMode(.alwaysTemplate)
self.starsAmountBackgroundView = starsAmountBackgroundView
if let starsAmountIconView = self.starsAmountIcon {
self.extractedContainerNode.contentNode.view.insertSubview(starsAmountBackgroundView, belowSubview: starsAmountIconView)
} else {
self.extractedContainerNode.contentNode.view.addSubview(starsAmountBackgroundView)
}
}
starsAmountBackgroundView.tintColor = StoryLiveChatMessageComponent.getMessageColor(color: baseColor).withMultipliedBrightnessBy(0.7).withMultipliedAlpha(0.5)
} else {
if let starsAmountBackgroundView = self.starsAmountBackgroundView {
self.starsAmountBackgroundView = nil
starsAmountBackgroundView.removeFromSuperview()
}
}
var textString = NSAttributedString()
if !component.message.text.isEmpty {
var underlineLinks = true
if !primaryTextColor.isEqual(component.theme.list.itemAccentColor) {
underlineLinks = false
}
let codeBlockTitleColor: UIColor
let codeBlockAccentColor: UIColor
let codeBlockBackgroundColor: UIColor
codeBlockTitleColor = primaryTextColor
codeBlockAccentColor = component.theme.list.itemAccentColor
if component.theme.overallDarkAppearance {
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65)
} else {
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05)
}
//let codeHighlightSpecs = extractMessageSyntaxHighlightSpecs(text: component.message.text, entities: component.message.entities)
let textFont = Font.regular(15.0)
let messageBoldFont = Font.semibold(15.0)
let messageItalicFont = Font.italic(15.0)
let messageBoldItalicFont = Font.semiboldItalic(15.0)
let messageFixedFont = Font.monospace(15.0)
let messageBlockQuoteFont = Font.regular(14.0)
textString = stringWithAppliedEntities(component.message.text, entities: component.message.entities, baseColor: primaryTextColor, linkColor: component.theme.list.itemAccentColor, baseQuoteTintColor: primaryTextColor, baseQuoteSecondaryTintColor: secondaryTextColor, baseQuoteTertiaryTintColor: secondaryTextColor, codeBlockTitleColor: codeBlockTitleColor, codeBlockAccentColor: codeBlockAccentColor, codeBlockBackgroundColor: codeBlockBackgroundColor, baseFont: textFont, linkFont: textFont, boldFont: messageBoldFont, italicFont: messageItalicFont, boldItalicFont: messageBoldItalicFont, fixedFont: messageFixedFont, blockQuoteFont: messageBlockQuoteFont, underlineLinks: underlineLinks, message: nil, adjustQuoteFontSize: true, cachedMessageSyntaxHighlight: nil)
}
var textTopLeftCutout: CGFloat = 0.0
if let topPlace = component.topPlace {
let crownIcon: UIImageView
if let current = self.crownIcon {
crownIcon = current
} else {
crownIcon = UIImageView()
self.crownIcon = crownIcon
self.extractedContainerNode.contentNode.view.addSubview(crownIcon)
}
if topPlace != previousComponent?.topPlace {
crownIcon.image = generateCrownImage(place: topPlace, backgroundColor: .white, foregroundColor: .clear, borderColor: nil)
}
crownIcon.tintColor = secondaryTextColor
if let image = crownIcon.image {
if !component.message.isFromAdmin {
textTopLeftCutout = image.size.width + 4.0
}
}
} else {
if let crownIcon = self.crownIcon {
self.crownIcon = nil
crownIcon.removeFromSuperview()
}
}
var textBottomRightCutout: CGFloat?
if let starsAmountTextSize, !displayStarsAmountBackground {
textBottomRightCutout = starsAmountTextSize.width + 20.0
}
var maxTextWidth: CGFloat = availableSize.width - insets.left - insets.right - avatarSize - avatarSpacing
if let starsAmountTextSize, displayStarsAmountBackground {
var cutoutWidth: CGFloat = starsAmountTextSize.width + 20.0
cutoutWidth += 30.0
maxTextWidth -= cutoutWidth
}
let textIconsForegroundColor: UIColor
if let paidStars = component.message.paidStars, let baseColor = GroupCallMessagesContext.getStarAmountParamMapping(params: LiveChatMessageParams(appConfig: component.context.currentAppConfiguration.with({ $0 })), value: paidStars).color {
textIconsForegroundColor = StoryLiveChatMessageComponent.getMessageColor(color: baseColor).withAlphaComponent(component.layout.transparentBackground ? 0.7 : 1.0)
} else {
textIconsForegroundColor = .black
}
let authorTitleSize = self.authorTitle.update(
transition: .immediate,
component: AnyComponent(PeerNameTextComponent(
context: component.context,
peer: component.message.author,
text: .name,
font: Font.semibold(15.0),
textColor: secondaryTextColor,
iconBackgroundColor: .white,
iconForegroundColor: textIconsForegroundColor,
strings: component.strings
)),
environment: {},
containerSize: CGSize(width: min(maxTextWidth - 80.0, 180.0), height: 100000.0)
)
if !component.message.isFromAdmin {
textTopLeftCutout += authorTitleSize.width + 6.0
}
var adminBadgeTextSize: CGSize?
if component.message.isFromAdmin && !displayStarsAmountBackground {
let adminBadgeText: ComponentView<Empty>
if let current = self.adminBadgeText {
adminBadgeText = current
} else {
adminBadgeText = ComponentView()
self.adminBadgeText = adminBadgeText
}
adminBadgeTextSize = adminBadgeText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.ChatAdmins_AdminLabel, font: Font.regular(11.0), textColor: secondaryTextColor))
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
} else {
if let adminBadgeText = self.adminBadgeText {
self.adminBadgeText = nil
adminBadgeText.view?.removeFromSuperview()
}
}
let textCutout = TextNodeCutout(topLeft: CGSize(width: textTopLeftCutout, height: 8.0), bottomRight: textBottomRightCutout.flatMap({ CGSize(width: $0, height: 8.0) }))
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextWithEntitiesComponent(
external: self.textExternal,
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: .gray,
text: .plain(textString),
maximumNumberOfLines: 0,
lineSpacing: 0.1,
cutout: textCutout,
handleSpoilers: true,
manualVisibilityControl: true
)),
environment: {},
containerSize: CGSize(width: maxTextWidth, height: 100000.0)
)
var avatarFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: avatarSize, height: avatarSize))
if component.message.paidStars != nil {
avatarFrame.origin.y += avatarBackgroundInset
if component.layout.fitToWidth {
avatarFrame.origin.x += avatarBackgroundInset
}
}
do {
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 10.0))
self.avatarNode = avatarNode
self.extractedContainerNode.contentNode.view.addSubview(avatarNode.view)
}
transition.setFrame(view: avatarNode.view, frame: avatarFrame)
avatarNode.updateSize(size: avatarFrame.size)
if let peer = component.message.author {
if peer.smallProfileImage != nil {
avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
} else {
avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
}
if component.message.isFromAdmin {
avatarNode.setStoryStats(storyStats: AvatarNode.StoryStats(totalCount: 1, unseenCount: 1, hasUnseenCloseFriendsItems: false, hasLiveItems: true), presentationParams: AvatarNode.StoryPresentationParams(colors: AvatarNode.Colors(theme: component.theme), lineWidth: 1.0, inactiveLineWidth: 1.0), transition: .immediate)
} else {
avatarNode.setStoryStats(storyStats: nil, presentationParams: AvatarNode.StoryPresentationParams(colors: AvatarNode.Colors(theme: component.theme), lineWidth: 1.0, inactiveLineWidth: 1.0), transition: .immediate)
}
} else {
avatarNode.setCustomLetters([" "])
avatarNode.setStoryStats(storyStats: nil, presentationParams: AvatarNode.StoryPresentationParams(colors: AvatarNode.Colors(theme: component.theme), lineWidth: 2.0, inactiveLineWidth: 2.0), transition: .immediate)
}
}
var authorTitleFrame = CGRect(origin: CGPoint(x: insets.left + avatarSize + avatarSpacing, y: avatarFrame.minY + 3.0), size: authorTitleSize)
if component.layout.fitToWidth {
authorTitleFrame.origin.x += 4.0
}
if let image = self.crownIcon?.image {
authorTitleFrame.origin.x += image.size.width + 4.0
}
if let authorTitleView = self.authorTitle.view {
if authorTitleView.superview == nil {
authorTitleView.layer.anchorPoint = CGPoint()
self.extractedContainerNode.contentNode.view.addSubview(authorTitleView)
}
transition.setPosition(view: authorTitleView, position: authorTitleFrame.origin)
authorTitleView.bounds = CGRect(origin: CGPoint(), size: authorTitleFrame.size)
}
var textFrame = CGRect(origin: CGPoint(x: insets.left + avatarSize + avatarSpacing, y: avatarFrame.minY + 3.0), size: textSize)
if component.layout.fitToWidth {
textFrame.origin.x += 4.0
}
if component.message.isFromAdmin {
textFrame.origin.y = authorTitleFrame.maxY
textFrame.size.width = max(textFrame.width, authorTitleFrame.maxX - textFrame.minX)
textFrame.size.height = max(textFrame.height, authorTitleFrame.height)
}
textFrame.size.width = max(textFrame.width, textTopLeftCutout + (textBottomRightCutout ?? 0.0))
if let textView = self.text.view as? MultilineTextWithEntitiesComponent.View {
if textView.superview == nil {
textView.layer.anchorPoint = CGPoint()
self.extractedContainerNode.contentNode.view.addSubview(textView)
}
transition.setPosition(view: textView, position: textFrame.origin)
textView.bounds = CGRect(origin: CGPoint(), size: textSize)
textView.updateVisibility(true)
}
if let crownIcon = self.crownIcon, let image = crownIcon.image {
crownIcon.frame = CGRect(origin: CGPoint(x: authorTitleFrame.minX - 4.0 - image.size.width, y: authorTitleFrame.minY - 1.0), size: image.size)
}
let backgroundOrigin = CGPoint(x: avatarFrame.minX - avatarBackgroundInset, y: avatarFrame.minY - avatarBackgroundInset)
var backgroundFrame = CGRect(origin: backgroundOrigin, size: CGSize(width: textFrame.maxX + 8.0 - backgroundOrigin.x, height: avatarFrame.maxY + avatarBackgroundInset - backgroundOrigin.y))
if let starsAmountTextSize, displayStarsAmountBackground {
backgroundFrame.size.width += starsAmountTextSize.width + 30.0
}
if let adminBadgeTextSize {
backgroundFrame.size.width = max(backgroundFrame.width, authorTitleFrame.maxX + 4.0 + adminBadgeTextSize.width + 10.0)
}
if let textLayout = self.textExternal.layout {
if textLayout.numberOfLines > 1 || (component.message.isFromAdmin && !displayStarsAmountBackground) {
backgroundFrame.size.height = max(backgroundFrame.size.height, textFrame.maxY + 8.0 - backgroundOrigin.y)
}
if let starsAmountTextSize, !displayStarsAmountBackground, let lastLineRect = textLayout.linesRects().last, textFrame.minX + lastLineRect.maxX > backgroundFrame.width - 8.0 - starsAmountTextSize.width {
backgroundFrame.size.height += starsAmountTextSize.height + 2.0
}
}
if let starsAmountTextSize, let starsAmountTextView = self.starsAmountText?.view, let starsAmountIcon = self.starsAmountIcon {
let starsAmountTextFrame: CGRect
if displayStarsAmountBackground, let starsAmountBackgroundView = self.starsAmountBackgroundView {
let starsAmountBackgroundSize = CGSize(width: starsAmountTextSize.width + 5.0 + 20.0, height: 20.0)
let starsAmountBackgroundFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 6.0 - starsAmountBackgroundSize.width, y: backgroundFrame.minY + floor((backgroundFrame.height - starsAmountBackgroundSize.height) * 0.5)), size: starsAmountBackgroundSize)
transition.setFrame(view: starsAmountBackgroundView, frame: starsAmountBackgroundFrame)
starsAmountTextFrame = CGRect(origin: CGPoint(x: starsAmountBackgroundFrame.maxX - starsAmountTextSize.width - 5.0, y: starsAmountBackgroundFrame.minY + UIScreenPixel + floor((starsAmountBackgroundFrame.height - starsAmountTextSize.height) * 0.5)), size: starsAmountTextSize)
} else {
starsAmountTextFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 8.0 - starsAmountTextSize.width, y: backgroundFrame.maxY - starsAmountTextSize.height - 8.0), size: starsAmountTextSize)
}
if starsAmountTextView.superview == nil {
starsAmountTextView.layer.anchorPoint = CGPoint(x: 1.0, y: 1.0)
self.extractedContainerNode.contentNode.view.addSubview(starsAmountTextView)
}
transition.setPosition(view: starsAmountTextView, position: CGPoint(x: starsAmountTextFrame.maxX, y: starsAmountTextFrame.maxY))
starsAmountTextView.bounds = CGRect(origin: CGPoint(), size: starsAmountTextFrame.size)
if let image = starsAmountIcon.image {
let starsAmountIconFrame = CGRect(origin: CGPoint(x: starsAmountTextFrame.minX - 2.0 - image.size.width, y: starsAmountTextFrame.minY + UIScreenPixel), size: image.size)
transition.setFrame(view: starsAmountIcon, frame: starsAmountIconFrame)
}
}
let size = CGSize(width: component.layout.fitToWidth ? backgroundFrame.maxX : availableSize.width, height: backgroundFrame.maxY)
let backgroundCornerRadius = (avatarSize + avatarBackgroundInset * 2.0) * 0.5
var displayBackground = false
if component.message.paidStars != nil {
displayBackground = true
} else if component.message.isFromAdmin {
displayBackground = true
}
if displayBackground {
let backgroundView: UIImageView
if let current = self.backgroundView {
backgroundView = current
} else {
backgroundView = UIImageView()
self.backgroundView = backgroundView
self.extractedContainerNode.contentNode.view.insertSubview(backgroundView, at: 0)
backgroundView.image = generateStretchableFilledCircleImage(diameter: backgroundCornerRadius * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate)
}
transition.setFrame(view: backgroundView, frame: backgroundFrame)
if let paidStars = component.message.paidStars, let baseColor = GroupCallMessagesContext.getStarAmountParamMapping(params: LiveChatMessageParams(appConfig: component.context.currentAppConfiguration.with({ $0 })), value: paidStars).color {
backgroundView.tintColor = StoryLiveChatMessageComponent.getMessageColor(color: baseColor)
backgroundView.alpha = component.layout.transparentBackground ? 0.5 : 1.0
} else {
backgroundView.tintColor = UIColor(white: 0.0, alpha: 0.3)
backgroundView.alpha = 1.0
}
if component.message.paidStars != nil {
let effectLayer: StarsParticleEffectLayer
if let current = self.effectLayer {
effectLayer = current
} else {
effectLayer = StarsParticleEffectLayer()
self.effectLayer = effectLayer
backgroundView.layer.addSublayer(effectLayer)
}
transition.setFrame(layer: effectLayer, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
effectLayer.update(color: UIColor(white: 1.0, alpha: 0.5), size: backgroundFrame.size, cornerRadius: backgroundCornerRadius, transition: transition)
} else {
if let effectLayer = self.effectLayer {
self.effectLayer = nil
effectLayer.removeFromSuperlayer()
}
}
} else if let backgroundView = self.backgroundView {
self.backgroundView = nil
backgroundView.removeFromSuperview()
if let effectLayer = self.effectLayer {
self.effectLayer = nil
effectLayer.removeFromSuperlayer()
}
}
if let adminBadgeTextView = self.adminBadgeText?.view, let adminBadgeTextSize {
let adminBadgeTextFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 10.0 - adminBadgeTextSize.width, y: backgroundFrame.minY + 9.0), size: adminBadgeTextSize)
if adminBadgeTextView.superview == nil {
self.extractedContainerNode.contentNode.view.addSubview(adminBadgeTextView)
}
transition.setFrame(view: adminBadgeTextView, frame: adminBadgeTextFrame)
}
let contentFrame = CGRect(origin: CGPoint(), size: size)
transition.setPosition(view: self.contentContainer, position: contentFrame.center)
transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: contentFrame.size))
self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentRect = backgroundFrame.insetBy(dx: -2.0, dy: 0.0)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
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)
}
public static func getMessageColor(color: GroupCallMessagesContext.Message.Color) -> UIColor {
return UIColor(rgb: color.rawValue)
}
private static let crownTemplateImage: UIImage = {
return generateTintedImage(image: UIImage(bundleImageName: "Stories/LiveChatCrown"), color: .white)!
}()
private static let crownFont: UIFont = {
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: 9.0)
} else {
font = UIFont(descriptor: descriptor, size: 9.0)
}
return font
}()
public static func generateCrownImage(place: Int, backgroundColor: UIColor, foregroundColor: UIColor, borderColor: UIColor?) -> UIImage {
let baseSize = crownTemplateImage.size
var borderWidth: CGFloat = 0.0
if borderColor != nil {
borderWidth = 2.0
}
var size = baseSize
size.width += borderWidth * 2.0
size.height += borderWidth * 2.0
let image = generateImage(size, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
if let borderColor {
generateTintedImage(image: UIImage(bundleImageName: "Stories/LiveChatCrown"), color: borderColor)!.draw(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size))
}
if backgroundColor != .white {
generateTintedImage(image: UIImage(bundleImageName: "Stories/LiveChatCrown"), color: backgroundColor)!.draw(in: CGRect(origin: CGPoint(x: borderWidth, y: borderWidth), size: baseSize))
} else {
crownTemplateImage.draw(in: CGRect(origin: CGPoint(x: borderWidth, y: borderWidth), size: baseSize))
}
if foregroundColor.alpha < 1.0 {
context.setBlendMode(.copy)
}
let string = NSAttributedString(string: "\(place + 1)", font: crownFont, textColor: foregroundColor)
let stringSize = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
let stringOffsets: [CGPoint] = [
CGPoint(x: 0.25, y: -0.33),
CGPoint(x: 0.24749999999999983, y: -0.495),
CGPoint(x: 0.0, y: -1.4025),
]
var stringPosition = CGPoint(x: borderWidth + floorToScreenPixels((baseSize.width - stringSize.width) * 0.5), y: borderWidth + floorToScreenPixels((baseSize.height - stringSize.height) * 0.5) + 1.0)
if place < stringOffsets.count {
stringPosition.x += stringOffsets[place].x * 0.8
stringPosition.y += stringOffsets[place].y * 0.8
}
string.draw(at: stringPosition)
})!
if backgroundColor == .white && foregroundColor == .clear && borderColor == nil {
return image.withRenderingMode(.alwaysTemplate)
} else {
return image
}
}
}
@@ -0,0 +1,39 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PeerListItemComponent",
module_name = "PeerListItemComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramPresentationData",
"//submodules/AsyncDisplayKit",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/Components/MultilineTextComponent",
"//submodules/AvatarNode",
"//submodules/CheckNode",
"//submodules/TelegramStringFormatting",
"//submodules/AppBundle",
"//submodules/PeerPresenceStatusManager",
"//submodules/TelegramUI/Components/EmojiStatusComponent",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/ContextUI",
"//submodules/TextFormat",
"//submodules/PhotoResources",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListItemSwipeOptionContainer",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,121 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StoryContainerScreen",
module_name = "StoryContainerScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/AsyncDisplayKit",
"//submodules/ComponentFlow",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/MessageInputPanelComponent",
"//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode",
"//submodules/TelegramUI/Components/ChatInputNode",
"//submodules/AccountContext",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AppBundle",
"//submodules/TelegramCore",
"//submodules/Postbox",
"//submodules/ShareController",
"//submodules/UndoUI",
"//submodules/AttachmentUI",
"//submodules/TelegramUIPreferences",
"//submodules/MediaPickerUI",
"//submodules/LegacyMediaPickerUI",
"//submodules/LocationUI",
"//submodules/TelegramUI/Components/ChatScheduleTimeController",
"//submodules/TelegramUI/Components/ChatTimerScreen",
"//submodules/TextFormat",
"//submodules/PhoneNumberFormat",
"//submodules/ComposePollUI",
"//submodules/TelegramIntents",
"//submodules/LegacyUI",
"//submodules/WebSearchUI",
"//submodules/PremiumUI",
"//submodules/ICloudResources",
"//submodules/LegacyComponents",
"//submodules/TelegramUI/Components/LegacyCamera",
"//submodules/TelegramUI/Components/LegacyInstantVideoController",
"//submodules/TelegramUI/Components/EntityKeyboard",
"//submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent",
"//submodules/TelegramUI/Components/Stories/PeerListItemComponent",
"//submodules/TelegramUI/Components/ShareWithPeersScreen",
"//submodules/TelegramUI/Components/MediaEditorScreen",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/TelegramPresentationData",
"//submodules/ReactionSelectionNode",
"//submodules/ContextUI",
"//submodules/AvatarNode",
"//submodules/ChatPresentationInterfaceState",
"//submodules/TelegramStringFormatting",
"//submodules/ShimmerEffect",
"//submodules/ImageCompression",
"//submodules/TelegramUI/Components/TextNodeWithEntities",
"//submodules/InvisibleInkDustNode",
"//submodules/PresentationDataUtils",
"//submodules/UrlEscaping",
"//submodules/OverlayStatusController",
"//submodules/Utils/VolumeButtons",
"//submodules/TelegramUI/Components/PeerReportScreen",
"//submodules/MediaResources",
"//submodules/LocalMediaResources",
"//submodules/SaveToCameraRoll",
"//submodules/Components/BundleIconComponent",
"//submodules/TinyThumbnail",
"//submodules/ImageBlur",
"//submodules/StickerPackPreviewUI",
"//submodules/Components/AnimatedStickerComponent",
"//submodules/OpenInExternalAppUI",
"//submodules/WebPBinding",
"//submodules/Utils/RangeSet",
"//submodules/TelegramUI/Components/Stories/StoryStealthModeSheetScreen",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/LottieComponentResourceContent",
"//submodules/TextSelectionNode",
"//submodules/Pasteboard",
"//submodules/Speak",
"//submodules/TranslateUI",
"//submodules/TelegramNotices",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/TelegramUniversalVideoContent",
"//submodules/TelegramUI/Components/NavigationSearchComponent",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/OptionButtonComponent",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView",
"//submodules/Components/BalancedTextComponent",
"//submodules/AnimatedCountLabelNode",
"//submodules/StickerResources",
"//submodules/TelegramUI/Components/Stories/ForwardInfoPanelComponent",
"//submodules/TelegramUI/Components/Stories/StoryQualityUpgradeSheetScreen",
"//submodules/TelegramUI/Components/SliderContextItem",
"//submodules/TelegramUI/Components/InteractiveTextComponent",
"//submodules/TelegramUI/Components/SaveProgressScreen",
"//submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController",
"//submodules/DirectMediaImageCache",
"//submodules/PromptUI",
"//submodules/TelegramCallsUI",
"//submodules/TelegramUI/Components/AsyncListComponent",
"//submodules/Components/MultilineTextWithEntitiesComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/Chat/ChatSendStarsScreen",
"//submodules/TelegramUI/Components/GlassBackgroundComponent",
"//submodules/TelegramUI/Components/Stories/LiveChat/StoryLiveChatMessageComponent",
"//submodules/TelegramUI/Components/StarsParticleEffect",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/TelegramUI/Components/AdminUserActionsSheet",
"//submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/Utils/LokiRng",
"//submodules/TelegramUI/Components/PeerNameTextComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,369 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import AvatarNode
import AppBundle
import AccountContext
import HierarchyTrackingLayer
import LokiRng
import SwiftSignalKit
import TelegramPresentationData
private let gradientColors: [NSArray] = [
[UIColor(rgb: 0xff516a).cgColor, UIColor(rgb: 0xff885e).cgColor],
[UIColor(rgb: 0xffa85c).cgColor, UIColor(rgb: 0xffcd6a).cgColor],
[UIColor(rgb: 0x665fff).cgColor, UIColor(rgb: 0x82b1ff).cgColor],
[UIColor(rgb: 0x54cb68).cgColor, UIColor(rgb: 0xa0de7e).cgColor],
[UIColor(rgb: 0x4acccd).cgColor, UIColor(rgb: 0x00fcfd).cgColor],
[UIColor(rgb: 0x2a9ef1).cgColor, UIColor(rgb: 0x72d5fd).cgColor],
[UIColor(rgb: 0xd669ed).cgColor, UIColor(rgb: 0xe0a2f3).cgColor],
]
private func avatarViewLettersImage(size: CGSize, peerId: EnginePeer.Id, letters: [String], isStory: Bool) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(size, false, 2.0)
let context = UIGraphicsGetCurrentContext()
context?.beginPath()
if isStory {
context?.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height).insetBy(dx: 4.0, dy: 4.0))
} else {
context?.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
}
context?.clip()
let colorIndex: Int
if peerId.namespace == .max {
colorIndex = 0
} else {
colorIndex = abs(Int(clamping: peerId.id._internalGetInt64Value()))
}
let colorsArray = gradientColors[colorIndex % gradientColors.count]
var locations: [CGFloat] = [1.0, 0.0]
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)!
context?.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
context?.setBlendMode(.normal)
let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1]))
let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 8.0), NSAttributedString.Key.foregroundColor: UIColor.white])
let line = CTLineCreateWithAttributedString(attributedString)
let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0)
let lineOrigin = CGPoint(x: floor(-lineBounds.origin.x + (size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floor(-lineBounds.origin.y + (size.height - lineBounds.size.height) / 2.0))
context?.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context?.scaleBy(x: 1.0, y: -1.0)
context?.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
context?.translateBy(x: lineOrigin.x, y: lineOrigin.y)
if let context = context {
CTLineDraw(line, context)
}
context?.translateBy(x: -lineOrigin.x, y: -lineOrigin.y)
if isStory {
context?.resetClip()
let lineWidth: CGFloat = 2.0
context?.setLineWidth(lineWidth)
context?.addEllipse(in: CGRect(origin: CGPoint(x: size.width * 0.5, y: size.height * 0.5), size: CGSize(width: size.width, height: size.height)).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context?.replacePathWithStrokedPath()
context?.clip()
let colors: [CGColor] = [
UIColor(rgb: 0x34C76F).cgColor,
UIColor(rgb: 0x3DA1FD).cgColor
]
var locations: [CGFloat] = [0.0, 1.0]
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())
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
private func makePeerBadgeImage(engine: TelegramEngine, peer: EnginePeer, count: Int) async -> UIImage {
let avatarSize: CGFloat = 16.0
let avatarInset: CGFloat = 2.0
let avatarIconSpacing: CGFloat = 2.0
let iconTextSpacing: CGFloat = 1.0
let iconSize: CGFloat = 10.0
let rightInset: CGFloat = 5.0
let text = NSAttributedString(string: countString(Int64(count)), font: Font.semibold(10.0), textColor: .white)
var textSize = text.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil).size
textSize.width = ceil(textSize.width)
textSize.height = ceil(textSize.height)
var avatarSourceImage: UIImage?
if let resource = smallestImageRepresentation(peer.profileImageRepresentations)?.resource, let peerReference = PeerReference(peer._asPeer()) {
let disposable = fetchedMediaResource(mediaBox: engine.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .avatar, reference: .avatar(peer: peerReference, resource: resource)).startStrict()
let signal = engine.account.postbox.mediaBox.resourceData(resource)
|> filter { $0.complete }
|> map { value -> Data? in
if value.complete {
return try? Data(contentsOf: URL(fileURLWithPath: value.path))
} else {
return nil
}
}
|> take(1)
|> timeout(0.9, queue: Queue.concurrentDefaultQueue(), alternate: .single(nil))
if let imageData = await signal.get() {
avatarSourceImage = UIImage(data: imageData)
}
disposable.dispose()
}
let size = CGSize(width: avatarInset + avatarSize + avatarIconSpacing + iconSize + iconTextSpacing + textSize.width + rightInset, height: avatarSize + avatarInset * 2.0)
return generateImage(size, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: 0xFFB10D).cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.height * 0.5).cgPath)
context.fillPath()
let avatarRect = CGRect(origin: CGPoint(x: avatarInset, y: avatarInset), size: CGSize(width: avatarSize, height: avatarSize))
if let avatarSourceImage {
context.addEllipse(in: avatarRect)
context.clip()
avatarSourceImage.draw(in: avatarRect)
context.resetClip()
} else if let image = avatarViewLettersImage(size: CGSize(width: avatarSize, height: avatarSize), peerId: peer.id, letters: peer.displayLetters, isStory: false) {
image.draw(in: avatarRect)
}
if let image = generateTintedImage(image: UIImage(bundleImageName: "Premium/Stars/ButtonStar"), color: .white) {
let iconFrame = CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarIconSpacing, y: floorToScreenPixels((size.height - iconSize) * 0.5) + 1.0), size: CGSize(width: iconSize, height: iconSize))
image.draw(in: iconFrame)
}
text.draw(at: CGPoint(x: avatarInset + avatarSize + avatarIconSpacing + iconSize + iconTextSpacing, y: floorToScreenPixels((size.height - textSize.height) * 0.5)))
})!
}
private actor LiveChatReactionItemTaskQueue {
private final class PeerTask {
let peer: EnginePeer
let count: Int
let completion: (UIImage) -> Void
init(peer: EnginePeer, count: Int, completion: @escaping (UIImage) -> Void) {
self.peer = peer
self.count = count
self.completion = completion
}
}
private let engine: TelegramEngine
private var tasks: [PeerTask] = []
init(engine: TelegramEngine) {
self.engine = engine
}
func add(peer: EnginePeer, count: Int, completion: @escaping (UIImage) -> Void) {
self.tasks.append(PeerTask(peer: peer, count: count, completion: completion))
if self.tasks.count == 1 {
Task {
await processTasks()
}
}
}
private func processTasks() async {
while !self.tasks.isEmpty {
let task = self.tasks.removeFirst()
let image = await makePeerBadgeImage(engine: self.engine, peer: task.peer, count: task.count)
task.completion(image)
}
}
}
final class LiveChatReactionStreamView: UIView {
private final class ItemLayer: SimpleLayer {
let amplitude: CGFloat
let period: CGFloat
let phaseOffset: CGFloat
let baseX: CGFloat
let verticalVelocity: CGFloat
var timeValue: CGFloat = 0.0
init(image: UIImage, amplitude: CGFloat, period: CGFloat, phaseOffset: CGFloat, baseX: CGFloat, verticalVelocity: CGFloat) {
self.amplitude = amplitude
self.period = period
self.phaseOffset = phaseOffset
self.baseX = baseX
self.verticalVelocity = verticalVelocity
super.init()
self.contents = image.cgImage
self.allowsEdgeAntialiasing = true
}
override init(layer: Any) {
self.amplitude = 0.0
self.period = 0.0
self.phaseOffset = 0.0
self.baseX = 0.0
self.verticalVelocity = 0.0
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private var nextId: Int = 0
private var itemLayers: [Int: ItemLayer] = [:]
private let itemLayerContainer: SimpleLayer
private let hierarchyTracker: HierarchyTrackingLayer
private var previousTimestamp: Double = 0.0
private var displayLink: SharedDisplayLinkDriver.Link?
private var previousPhysicsTimestamp: Double = 0.0
private let taskQueue: LiveChatReactionItemTaskQueue
init(context: AccountContext) {
self.itemLayerContainer = SimpleLayer()
self.hierarchyTracker = HierarchyTrackingLayer()
self.taskQueue = LiveChatReactionItemTaskQueue(engine: context.engine)
super.init(frame: CGRect())
self.layer.addSublayer(self.itemLayerContainer)
self.layer.addSublayer(self.hierarchyTracker)
self.hierarchyTracker.isInHierarchyUpdated = { [weak self] inHierarchy in
guard let self else {
return
}
if inHierarchy {
if self.displayLink == nil {
self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
guard let self else {
return
}
self.updatePhysics()
})
}
} else {
self.displayLink = nil
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func add(peer: EnginePeer, count: Int) {
if !self.hierarchyTracker.isInHierarchy {
return
}
let timestamp = CFAbsoluteTimeGetCurrent()
if timestamp < self.previousTimestamp + 0.2 {
return
}
self.previousTimestamp = timestamp
Task {
await self.taskQueue.add(peer: peer, count: count, completion: { [weak self] image in
Task { @MainActor in
guard let self else {
return
}
self.addRenderedItem(image: image)
}
})
}
}
private func addRenderedItem(image: UIImage) {
let id = self.nextId
self.nextId += 1
let random = LokiRng(seed0: UInt(id), seed1: 1, seed2: 0)
let itemX: CGFloat = -image.size.width - 8.0 + 20.0 * CGFloat(LokiRng.random(withSeed0: UInt(id), seed1: 0, seed2: 0) - 0.5)
let phaseOffset: CGFloat = CGFloat(random.next())
let itemLayer = ItemLayer(image: image, amplitude: 0.0 + CGFloat(random.next()) * 6.0, period: 1.5 + CGFloat(random.next()) * 2.0, phaseOffset: phaseOffset, baseX: itemX, verticalVelocity: -(1.0 + CGFloat(random.next()) * 0.2) * 90.0)
itemLayer.frame = CGRect(origin: CGPoint(x: itemX, y: -image.size.height * 0.5), size: image.size)
self.itemLayers[id] = itemLayer
self.itemLayerContainer.addSublayer(itemLayer)
let itemDuration: Double = 1.2 + Double(random.next()) * 0.8
itemLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, completion: { [weak self, weak itemLayer] _ in
guard let itemLayer else {
return
}
let delay: Double = itemDuration - 0.1 - 0.18
let transition: ComponentTransition = .easeInOut(duration: 0.2)
transition.animateBlur(layer: itemLayer, fromRadius: 0.0, toRadius: 8.0, delay: delay)
itemLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, delay: delay, removeOnCompletion: false)
itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, delay: delay, removeOnCompletion: false, completion: { [weak self] _ in
guard let self else {
return
}
if let itemLayer = self.itemLayers[id] {
self.itemLayers.removeValue(forKey: id)
itemLayer.removeFromSuperlayer()
}
})
})
}
private func updatePhysics() {
let timestamp = CACurrentMediaTime()
let dt = max(1.0 / 120.0, min(1.0 / 30.0, timestamp - self.previousPhysicsTimestamp))
self.previousPhysicsTimestamp = timestamp
CATransaction.begin()
CATransaction.setDisableActions(true)
for (_, itemLayer) in self.itemLayers {
itemLayer.timeValue += dt
let itemPhase = (itemLayer.timeValue.truncatingRemainder(dividingBy: itemLayer.period) / itemLayer.period + itemLayer.phaseOffset).truncatingRemainder(dividingBy: 1.0)
let phaseAngle = itemPhase * CGFloat.pi * 2.0
let phaseFraction = sin(phaseAngle)
let newX = itemLayer.baseX + phaseFraction * itemLayer.amplitude
let newY = itemLayer.position.y + itemLayer.verticalVelocity * dt
itemLayer.position = CGPoint(x: newX, y: newY)
let horizontalVelocity = itemLayer.amplitude * cos(phaseAngle) * (CGFloat.pi * 2.0 / itemLayer.period)
let rotationAngle = atan2(itemLayer.verticalVelocity, horizontalVelocity) + CGFloat.pi * 0.5
itemLayer.setValue(rotationAngle, forKeyPath: "transform.rotation.z")
}
CATransaction.commit()
}
func update(size: CGSize, sourcePoint: CGPoint, transition: ComponentTransition) {
transition.setFrame(layer: self.itemLayerContainer, frame: CGRect(origin: sourcePoint, size: CGSize()))
}
}
@@ -0,0 +1,45 @@
import Foundation
import UIKit
import Display
import TelegramCore
final class MediaAreaMaskLayer: CALayer {
private var params: (referenceSize: CGSize, mediaAreas: [MediaArea])?
func update(referenceSize: CGSize, mediaAreas: [MediaArea], borderMaskLayer: CALayer?) {
guard referenceSize != self.params?.referenceSize && mediaAreas != self.params?.mediaAreas else {
return
}
for mediaArea in mediaAreas {
let size = CGSize(width: mediaArea.coordinates.width / 100.0 * referenceSize.width, height: mediaArea.coordinates.height / 100.0 * referenceSize.height)
let position = CGPoint(x: mediaArea.coordinates.x / 100.0 * referenceSize.width, y: mediaArea.coordinates.y / 100.0 * referenceSize.height)
let cornerRadius: CGFloat
if let radius = mediaArea.coordinates.cornerRadius {
cornerRadius = radius / 100.0 * size.width
} else {
cornerRadius = size.height * 0.18
}
let layer = CALayer()
layer.backgroundColor = UIColor.white.cgColor
layer.bounds = CGRect(origin: .zero, size: size)
layer.position = position
layer.cornerRadius = cornerRadius
layer.transform = CATransform3DMakeRotation(mediaArea.coordinates.rotation * Double.pi / 180.0, 0.0, 0.0, 1.0)
self.addSublayer(layer)
if let borderMaskLayer {
let borderLayer = CAShapeLayer()
borderLayer.strokeColor = UIColor.white.cgColor
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.lineWidth = 2.0
borderLayer.path = CGPath(roundedRect: CGRect(origin: .zero, size: size), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
borderLayer.bounds = CGRect(origin: .zero, size: size)
borderLayer.position = position
borderLayer.transform = layer.transform
borderMaskLayer.addSublayer(borderLayer)
}
}
}
}
@@ -0,0 +1,297 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import HierarchyTrackingLayer
final class MediaNavigationStripComponent: Component {
final class EnvironmentType: Equatable {
let currentProgress: Double
let currentIsBuffering: Bool
init(currentProgress: Double, currentIsBuffering: Bool) {
self.currentProgress = currentProgress
self.currentIsBuffering = currentIsBuffering
}
static func ==(lhs: EnvironmentType, rhs: EnvironmentType) -> Bool {
if lhs.currentProgress != rhs.currentProgress {
return false
}
if lhs.currentIsBuffering != rhs.currentIsBuffering {
return false
}
return true
}
}
let index: Int
let count: Int
let isSeeking: Bool
init(index: Int, count: Int, isSeeking: Bool) {
self.index = index
self.count = count
self.isSeeking = isSeeking
}
static func ==(lhs: MediaNavigationStripComponent, rhs: MediaNavigationStripComponent) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.isSeeking != rhs.isSeeking {
return false
}
return true
}
private final class ItemLayer: SimpleLayer {
let foregroundLayer: SimpleLayer
var bufferingGradientLayer: SimpleGradientLayer?
var bufferingLayer: HierarchyTrackingLayer?
override init() {
self.foregroundLayer = SimpleLayer()
super.init()
self.cornerRadius = 1.5
self.foregroundLayer.cornerRadius = 1.5
self.addSublayer(self.foregroundLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
self.foregroundLayer = SimpleLayer()
super.init(layer: layer)
}
private func updateProgressAnimation(size: CGSize) {
guard let bufferingGradientLayer = self.bufferingGradientLayer else {
return
}
let gradientWidth: CGFloat = floor(max(10.0, size.width * 0.6))
bufferingGradientLayer.frame = CGRect(origin: CGPoint(x: -gradientWidth, y: 0.0), size: CGSize(width: gradientWidth, height: 3.0))
let animation = bufferingGradientLayer.makeAnimation(from: 0.0 as NSNumber, to: (size.width + gradientWidth * 2.0) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.6, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
bufferingGradientLayer.add(animation, forKey: "shimmer")
}
func updateIsBuffering(size: CGSize, isBuffering: Bool) {
if isBuffering {
let bufferingLayer: HierarchyTrackingLayer
if let current = self.bufferingLayer {
bufferingLayer = current
} else {
bufferingLayer = HierarchyTrackingLayer()
bufferingLayer.cornerRadius = 1.5
bufferingLayer.masksToBounds = true
self.bufferingLayer = bufferingLayer
self.addSublayer(bufferingLayer)
bufferingLayer.didEnterHierarchy = { [weak self] in
guard let self, !self.bounds.isEmpty else {
return
}
self.updateProgressAnimation(size: self.bounds.size)
}
}
let bufferingGradientLayer: SimpleGradientLayer
if let current = self.bufferingGradientLayer {
bufferingGradientLayer = current
} else {
bufferingGradientLayer = SimpleGradientLayer()
self.bufferingGradientLayer = bufferingGradientLayer
bufferingLayer.addSublayer(bufferingGradientLayer)
let colorCount = 7
bufferingGradientLayer.colors = (0 ..< colorCount).map { index -> CGColor in
let t = CGFloat(index) / CGFloat(colorCount - 1)
let fraction: CGFloat = 1.0 - max(0.0, min(1.0, abs(0.5 - t) / 0.5))
return UIColor(white: 1.0, alpha: 0.3 * sin(fraction)).cgColor
}
}
bufferingLayer.frame = CGRect(origin: CGPoint(), size: size)
self.updateProgressAnimation(size: size)
} else if let bufferingLayer = self.bufferingLayer {
self.bufferingLayer = nil
self.bufferingGradientLayer = nil
bufferingLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak bufferingLayer] _ in
bufferingLayer?.removeFromSuperlayer()
})
}
}
}
final class View: UIView {
private var visibleItems: [Int: ItemLayer] = [:]
private var component: MediaNavigationStripComponent?
override init(frame: CGRect) {
super.init(frame: frame)
self.clipsToBounds = true
self.layer.cornerRadius = 1.0
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateCurrentItemProgress(value: CGFloat, isBuffering: Bool, transition: ComponentTransition) {
guard let component = self.component else {
return
}
guard let itemLayer = self.visibleItems[component.index] else {
return
}
guard !self.isTransitioning else {
return
}
let itemFrame = itemLayer.bounds
transition.setFrame(layer: itemLayer.foregroundLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: value * itemFrame.width, height: itemFrame.height)))
itemLayer.updateIsBuffering(size: itemFrame.size, isBuffering: isBuffering)
}
private var isTransitioning = false
func update(component: MediaNavigationStripComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
let environment = environment[EnvironmentType.self].value
let spacing: CGFloat = 3.0
let itemHeight: CGFloat = 2.0
let minItemWidth: CGFloat = 2.0
var didSetCompletion = false
var validIndices: [Int] = []
if component.count != 0 {
let idealItemWidth: CGFloat = (availableSize.width - CGFloat(component.count - 1) * spacing) / CGFloat(component.count)
let itemWidth: CGFloat
if idealItemWidth < minItemWidth {
itemWidth = minItemWidth
} else {
itemWidth = idealItemWidth
}
var globalWidth: CGFloat = CGFloat(component.count) * itemWidth + CGFloat(component.count - 1) * spacing
if globalWidth > availableSize.width && globalWidth <= availableSize.width + 1.0 {
globalWidth = availableSize.width
}
let globalFocusedFrame = CGRect(origin: CGPoint(x: CGFloat(component.index) * (itemWidth + spacing), y: 0.0), size: CGSize(width: itemWidth, height: itemHeight))
var globalOffset: CGFloat = floor(globalFocusedFrame.midX - availableSize.width * 0.5)
if globalOffset > globalWidth - availableSize.width {
globalOffset = globalWidth - availableSize.width
}
if globalOffset < 0.0 {
globalOffset = 0.0
}
let potentiallyVisibleCount = Int(ceil((availableSize.width + spacing) / (itemWidth + spacing)))
let overflowDistance: CGFloat = 24.0
let potentialOverflowCount = 10
let _ = overflowDistance
for i in (component.index - potentiallyVisibleCount) ... (component.index + potentiallyVisibleCount + potentialOverflowCount) {
if i < 0 {
continue
}
if i >= component.count {
continue
}
var itemFrame = CGRect(origin: CGPoint(x: -globalOffset + CGFloat(i) * (itemWidth + spacing), y: 0.0), size: CGSize(width: itemWidth, height: itemHeight))
if component.isSeeking {
itemFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: 6.0))
}
if itemFrame.maxX < 0.0 || itemFrame.minX >= availableSize.width {
continue
}
validIndices.append(i)
let itemLayer: ItemLayer
if let current = self.visibleItems[i] {
itemLayer = current
} else {
itemLayer = ItemLayer()
self.layer.addSublayer(itemLayer)
self.visibleItems[i] = itemLayer
}
transition.setFrame(layer: itemLayer, frame: itemFrame)
transition.setCornerRadius(layer: itemLayer, cornerRadius: itemFrame.height * 0.5)
transition.setCornerRadius(layer: itemLayer.foregroundLayer, cornerRadius: itemFrame.height * 0.5, completion: transition.animation.isImmediate || didSetCompletion ? nil : { [weak self] _ in
if let self {
self.isTransitioning = false
}
})
if !transition.animation.isImmediate && component.isSeeking != previousComponent?.isSeeking {
self.isTransitioning = true
didSetCompletion = true
}
itemLayer.backgroundColor = UIColor(white: 1.0, alpha: 0.5).cgColor
itemLayer.foregroundLayer.backgroundColor = UIColor(white: 1.0, alpha: 1.0).cgColor
let itemProgress: CGFloat
var itemIsBuffering = false
if i < component.index {
itemProgress = 1.0
} else if i == component.index {
itemProgress = max(0.0, min(1.0, environment.currentProgress))
itemIsBuffering = environment.currentIsBuffering
} else {
itemProgress = 0.0
}
transition.setFrame(layer: itemLayer.foregroundLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: itemProgress * itemFrame.width, height: itemFrame.height)))
transition.setAlpha(layer: itemLayer, alpha: !component.isSeeking || i == component.index ? 1.0 : 0.0)
itemLayer.updateIsBuffering(size: itemFrame.size, isBuffering: itemIsBuffering)
}
}
var removedIndices: [Int] = []
for (index, itemLayer) in self.visibleItems {
if !validIndices.contains(index) {
removedIndices.append(index)
itemLayer.removeFromSuperlayer()
}
}
for index in removedIndices {
self.visibleItems.removeValue(forKey: index)
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
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)
}
}
@@ -0,0 +1,222 @@
import Foundation
import UIKit
import Display
import AccountContext
import SwiftSignalKit
import TelegramCore
import Postbox
import AvatarNode
public extension StoryContainerScreen {
static func openArchivedStories(context: AccountContext, parentController: ViewController, avatarNode: AvatarNode, sharedProgressDisposable: MetaDisposable?) {
let storyContent = StoryContentContextImpl(context: context, isHidden: true, focusedPeerId: nil, singlePeer: false)
let signal = storyContent.state
|> take(1)
|> mapToSignal { state -> Signal<Void, NoError> in
if let slice = state.slice {
return waitUntilStoryMediaPreloaded(context: context, peerId: slice.effectivePeer.id, storyItem: slice.item.storyItem)
|> timeout(4.0, queue: .mainQueue(), alternate: .complete())
|> map { _ -> Void in
}
|> then(.single(Void()))
} else {
return .single(Void())
}
}
|> deliverOnMainQueue
|> map { [weak parentController, weak avatarNode] _ -> Void in
var transitionIn: StoryContainerScreen.TransitionIn?
if let avatarNode {
transitionIn = StoryContainerScreen.TransitionIn(
sourceView: avatarNode.view,
sourceRect: avatarNode.view.bounds,
sourceCornerRadius: avatarNode.view.bounds.width * 0.5,
sourceIsAvatar: false
)
avatarNode.isHidden = true
}
let storyContainerScreen = StoryContainerScreen(
context: context,
content: storyContent,
transitionIn: transitionIn,
transitionOut: { peerId, _ in
if let avatarNode {
let destinationView = avatarNode.view
return StoryContainerScreen.TransitionOut(
destinationView: destinationView,
transitionView: StoryContainerScreen.TransitionView(
makeView: { [weak destinationView] in
let parentView = UIView()
if let copyView = destinationView?.snapshotContentTree(unhide: true) {
parentView.addSubview(copyView)
}
return parentView
},
updateView: { copyView, state, transition in
guard let view = copyView.subviews.first else {
return
}
let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress)
transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5))
transition.setScale(view: view, scale: size.width / state.destinationSize.width)
},
insertCloneTransitionView: nil
),
destinationRect: destinationView.bounds,
destinationCornerRadius: destinationView.bounds.width * 0.5,
destinationIsAvatar: false,
completed: { [weak avatarNode] in
guard let avatarNode else {
return
}
avatarNode.isHidden = false
}
)
} else {
return nil
}
}
)
parentController?.push(storyContainerScreen)
}
|> ignoreValues
let disposable = avatarNode.pushLoadingStatus(signal: signal)
if let sharedProgressDisposable {
sharedProgressDisposable.set(disposable)
}
}
static func openPeerStories(context: AccountContext, peerId: EnginePeer.Id, parentController: ViewController, avatarNode: AvatarNode?, sharedProgressDisposable: MetaDisposable? = nil) {
return openPeerStoriesCustom(
context: context,
peerId: peerId,
isHidden: false,
singlePeer: true,
parentController: parentController,
transitionIn: { [weak avatarNode] in
if let avatarNode {
let transitionIn = StoryContainerScreen.TransitionIn(
sourceView: avatarNode.view,
sourceRect: avatarNode.view.bounds,
sourceCornerRadius: avatarNode.view.bounds.width * 0.5,
sourceIsAvatar: false
)
avatarNode.isHidden = true
return transitionIn
} else {
return nil
}
},
transitionOut: { [weak avatarNode] _ in
if let avatarNode {
let destinationView = avatarNode.view
return StoryContainerScreen.TransitionOut(
destinationView: destinationView,
transitionView: StoryContainerScreen.TransitionView(
makeView: { [weak destinationView] in
let parentView = UIView()
if let copyView = destinationView?.snapshotContentTree(unhide: true) {
parentView.addSubview(copyView)
}
return parentView
},
updateView: { copyView, state, transition in
guard let view = copyView.subviews.first else {
return
}
let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress)
transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5))
transition.setScale(view: view, scale: size.width / state.destinationSize.width)
},
insertCloneTransitionView: nil
),
destinationRect: destinationView.bounds,
destinationCornerRadius: destinationView.bounds.width * 0.5,
destinationIsAvatar: false,
completed: { [weak avatarNode] in
guard let avatarNode else {
return
}
avatarNode.isHidden = false
}
)
} else {
return nil
}
},
setFocusedItem: { _ in
},
setProgress: { [weak avatarNode] signal in
guard let avatarNode else {
return
}
let disposable = avatarNode.pushLoadingStatus(signal: signal)
if let sharedProgressDisposable {
sharedProgressDisposable.set(disposable)
}
}
)
}
static func openPeerStoriesCustom(
context: AccountContext,
peerId: EnginePeer.Id,
focusOnId: Int32? = nil,
isHidden: Bool,
initialOrder: [EnginePeer.Id] = [],
singlePeer: Bool,
parentController: ViewController,
transitionIn: @escaping () -> StoryContainerScreen.TransitionIn?,
transitionOut: @escaping (EnginePeer.Id) -> StoryContainerScreen.TransitionOut?,
setFocusedItem: @escaping (Signal<StoryId?, NoError>) -> Void,
setProgress: @escaping (Signal<Never, NoError>) -> Void,
completion: @escaping (StoryContainerScreen) -> Void = { _ in }
) {
let storyContent = StoryContentContextImpl(context: context, isHidden: isHidden, focusedPeerId: peerId, focusedStoryId: focusOnId, singlePeer: singlePeer, fixedOrder: initialOrder)
let signal = storyContent.state
|> take(1)
|> mapToSignal { state -> Signal<StoryContentContextState, NoError> in
if let slice = state.slice {
#if DEBUG && false
if "".isEmpty {
return .single(state)
|> delay(4.0, queue: .mainQueue())
}
#endif
return waitUntilStoryMediaPreloaded(context: context, peerId: slice.effectivePeer.id, storyItem: slice.item.storyItem)
|> timeout(4.0, queue: .mainQueue(), alternate: .complete())
|> map { _ -> StoryContentContextState in
}
|> then(.single(state))
} else {
return .single(state)
}
}
|> deliverOnMainQueue
|> map { [weak parentController] state -> Void in
if state.slice == nil {
return
}
let transitionIn: StoryContainerScreen.TransitionIn? = transitionIn()
let storyContainerScreen = StoryContainerScreen(
context: context,
content: storyContent,
transitionIn: transitionIn,
transitionOut: { peerId, _ in
return transitionOut(peerId)
}
)
setFocusedItem(storyContainerScreen.focusedItem)
parentController?.push(storyContainerScreen)
completion(storyContainerScreen)
}
|> ignoreValues
setProgress(signal)
}
}
@@ -0,0 +1,478 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import AccountContext
import TelegramCore
import TelegramPresentationData
import SwiftSignalKit
import TelegramCallsUI
import AsyncListComponent
import AvatarNode
import ContextUI
import StarsParticleEffect
import StoryLiveChatMessageComponent
import PeerNameTextComponent
private final class PinnedBarMessageComponent: Component {
let context: AccountContext
let strings: PresentationStrings
let theme: PresentationTheme
let message: GroupCallMessagesContext.Message
let topPlace: Int?
let action: () -> Void
let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?
init(context: AccountContext, strings: PresentationStrings, theme: PresentationTheme, message: GroupCallMessagesContext.Message, topPlace: Int?, action: @escaping () -> Void, contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?) {
self.context = context
self.strings = strings
self.theme = theme
self.message = message
self.topPlace = topPlace
self.action = action
self.contextGesture = contextGesture
}
static func ==(lhs: PinnedBarMessageComponent, rhs: PinnedBarMessageComponent) -> Bool {
if lhs === rhs {
return true
}
if lhs.context !== rhs.context {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.message != rhs.message {
return false
}
if lhs.topPlace != rhs.topPlace {
return false
}
if (lhs.contextGesture == nil) != (rhs.contextGesture == nil) {
return false
}
return true
}
final class View: UIView {
private let extractedContainerNode: ContextExtractedContentContainingNode
private let containerNode: ContextControllerSourceNode
private let backgroundView: UIImageView
private let foregroundClippingView: UIView
private let foregroundView: UIImageView
private let effectLayer: StarsParticleEffectLayer
private var avatarNode: AvatarNode?
private let title = ComponentView<Empty>()
private var crownIcon: UIImageView?
private var component: PinnedBarMessageComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var updateTimer: Foundation.Timer?
override init(frame: CGRect) {
self.extractedContainerNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.backgroundView = UIImageView()
self.foregroundClippingView = UIView()
self.foregroundClippingView.clipsToBounds = true
self.foregroundView = UIImageView()
self.effectLayer = StarsParticleEffectLayer()
super.init(frame: frame)
self.containerNode.addSubnode(self.extractedContainerNode)
self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode
self.addSubview(self.containerNode.view)
self.containerNode.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
return
}
component.contextGesture?(gesture, self.extractedContainerNode)
}
self.extractedContainerNode.contentNode.view.addSubview(self.backgroundView)
self.foregroundClippingView.addSubview(self.foregroundView)
self.extractedContainerNode.contentNode.view.addSubview(self.foregroundClippingView)
self.extractedContainerNode.contentNode.view.layer.addSublayer(self.effectLayer)
self.extractedContainerNode.contentNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.updateTimer?.invalidate()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
component.action()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
func update(component: PinnedBarMessageComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
if self.updateTimer == nil {
self.updateTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
if !self.isUpdating {
self.state?.updated(transition: .immediate, isLocal: true)
}
})
}
let previousComponent = self.component
self.component = component
self.state = state
self.containerNode.isGestureEnabled = component.contextGesture != nil
let params = LiveChatMessageParams(appConfig: component.context.currentAppConfiguration.with({ $0 }))
let baseColor = StoryLiveChatMessageComponent.getMessageColor(color: GroupCallMessagesContext.getStarAmountParamMapping(params: params, value: component.message.paidStars ?? 0).color ?? GroupCallMessagesContext.Message.Color(rawValue: 0x985FDC))
let itemHeight: CGFloat = 32.0
let avatarInset: CGFloat = 4.0
let avatarSize: CGFloat = 24.0
let avatarSpacing: CGFloat = 6.0
let rightInset: CGFloat = 10.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(PeerNameTextComponent(
context: component.context,
peer: component.message.author,
text: .name,
font: Font.semibold(15.0),
textColor: .white,
iconBackgroundColor: .white,
iconForegroundColor: baseColor,
strings: component.strings
)),
environment: {},
containerSize: CGSize(width: 200.0, height: itemHeight)
)
var size = CGSize(width: avatarInset + avatarSize + avatarSpacing + titleSize.width + rightInset, height: itemHeight)
if let topPlace = component.topPlace {
let crownIcon: UIImageView
if let current = self.crownIcon {
crownIcon = current
} else {
crownIcon = UIImageView()
self.crownIcon = crownIcon
self.extractedContainerNode.contentNode.view.addSubview(crownIcon)
}
if topPlace != previousComponent?.topPlace {
crownIcon.image = StoryLiveChatMessageComponent.generateCrownImage(place: topPlace, backgroundColor: .white, foregroundColor: .clear, borderColor: nil)
}
crownIcon.tintColor = .white
if let image = crownIcon.image {
size.width += image.size.width + 4.0
}
} else {
if let crownIcon = self.crownIcon {
self.crownIcon = nil
crownIcon.removeFromSuperview()
}
}
if self.backgroundView.image == nil {
self.backgroundView.image = generateStretchableFilledCircleImage(diameter: itemHeight, color: .white)?.withRenderingMode(.alwaysTemplate)
self.foregroundView.image = self.backgroundView.image
}
self.backgroundView.tintColor = baseColor.withMultipliedBrightnessBy(0.7)
self.foregroundView.tintColor = baseColor
let timestamp = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970
let currentDuration = max(0.0, timestamp - Double(component.message.date))
var timeFraction: CGFloat = 1.0 - min(1.0, currentDuration / Double(component.message.lifetime))
if case .local = component.message.id.space {
timeFraction = 1.0
}
let backgroundFrame = CGRect(origin: CGPoint(), size: size)
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.foregroundClippingView, frame: CGRect(origin: CGPoint(), size: CGSize(width: floorToScreenPixels(size.width * timeFraction), height: size.height)))
transition.setFrame(layer: self.effectLayer, frame: CGRect(origin: CGPoint(), size: size))
self.effectLayer.update(color: UIColor(white: 1.0, alpha: 0.5), size: size, cornerRadius: size.height * 0.5, transition: transition)
let avatarFrame = CGRect(origin: CGPoint(x: avatarInset, y: floor((itemHeight - avatarSize) * 0.5)), size: CGSize(width: avatarSize, height: avatarSize))
do {
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 10.0))
self.avatarNode = avatarNode
self.extractedContainerNode.contentNode.view.addSubview(avatarNode.view)
}
transition.setFrame(view: avatarNode.view, frame: avatarFrame)
avatarNode.updateSize(size: avatarFrame.size)
if let peer = component.message.author {
if peer.smallProfileImage != nil {
avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
} else {
avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
}
} else {
avatarNode.setCustomLetters([" "])
}
}
var titleFrame = CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarSpacing, y: floor((itemHeight - titleSize.height) * 0.5)), size: titleSize)
if let crownIcon = self.crownIcon, let image = crownIcon.image {
crownIcon.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.minY - 1.0), size: image.size)
titleFrame.origin.x += image.size.width + 4.0
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
self.extractedContainerNode.contentNode.view.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerNode.contentRect = backgroundFrame.insetBy(dx: -4.0, dy: 0.0)
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
return CGSize(width: size.width + 10.0, height: size.height)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class PinnedBarComponent: Component {
let context: AccountContext
let strings: PresentationStrings
let theme: PresentationTheme
let isExpanded: Bool
let messages: [GroupCallMessagesContext.Message]
let topIndices: [EnginePeer.Id: Int]
let action: (GroupCallMessagesContext.Message) -> Void
let contextGesture: (GroupCallMessagesContext.Message, ContextGesture, ContextExtractedContentContainingNode) -> Void
init(context: AccountContext, strings: PresentationStrings, theme: PresentationTheme, isExpanded: Bool, messages: [GroupCallMessagesContext.Message], topIndices: [EnginePeer.Id: Int], action: @escaping (GroupCallMessagesContext.Message) -> Void, contextGesture: @escaping (GroupCallMessagesContext.Message, ContextGesture, ContextExtractedContentContainingNode) -> Void) {
self.context = context
self.strings = strings
self.theme = theme
self.isExpanded = isExpanded
self.messages = messages
self.topIndices = topIndices
self.action = action
self.contextGesture = contextGesture
}
static func ==(lhs: PinnedBarComponent, rhs: PinnedBarComponent) -> Bool {
if lhs === rhs {
return true
}
if lhs.context !== rhs.context {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
if lhs.messages != rhs.messages {
return false
}
if lhs.topIndices != rhs.topIndices {
return false
}
return true
}
final class View: UIView {
private let listContainer: UIView
private let listState = AsyncListComponent.ExternalState()
private let list = ComponentView<Empty>()
private var component: PinnedBarComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
override init(frame: CGRect) {
self.listContainer = UIView()
self.listContainer.clipsToBounds = true
super.init(frame: frame)
self.addSubview(self.listContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
func update(component: PinnedBarComponent, 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 itemHeight: CGFloat = 32.0
let insets = UIEdgeInsets(top: 13.0, left: 20.0, bottom: 13.0, right: 20.0)
let size = CGSize(width: availableSize.width, height: insets.top + itemHeight + insets.bottom)
var listItems: [AnyComponentWithIdentity<Empty>] = []
for message in component.messages {
if let author = message.author {
let id = message.id
listItems.append(AnyComponentWithIdentity(id: author.id, component: AnyComponent(PinnedBarMessageComponent(
context: component.context,
strings: component.strings,
theme: component.theme,
message: message,
topPlace: message.author.flatMap { component.topIndices[$0.id] },
action: { [weak self] in
guard let self, let component = self.component else {
return
}
if let message = component.messages.first(where: { $0.id == id }) {
component.action(message)
}
},
contextGesture: message.isIncoming ? { [weak self] gesture, sourceNode in
guard let self, let component = self.component else {
return
}
if let message = component.messages.first(where: { $0.id == id }) {
component.contextGesture(message, gesture, sourceNode)
} else {
gesture.cancel()
}
} : nil
))))
}
}
let listInsets = UIEdgeInsets(top: 0.0, left: insets.left, bottom: 0.0, right: insets.right)
let listFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
var listTransition = transition
var animateIn = false
if let previousComponent {
if previousComponent.messages.isEmpty {
listTransition = listTransition.withAnimation(.none)
animateIn = true
}
} else {
listTransition = listTransition.withAnimation(.none)
animateIn = true
}
let _ = self.list.update(
transition: listTransition,
component: AnyComponent(AsyncListComponent(
externalState: self.listState,
items: listItems,
itemSetId: AnyHashable(0),
direction: .horizontal,
insets: listInsets
)),
environment: {},
containerSize: listFrame.size
)
if let listView = self.list.view {
if listView.superview == nil {
self.listContainer.addSubview(listView)
}
transition.setPosition(view: listView, position: CGRect(origin: CGPoint(), size: listFrame.size).center)
transition.setBounds(view: listView, bounds: CGRect(origin: CGPoint(), size: listFrame.size))
if animateIn {
transition.animateAlpha(view: listView, from: 0.0, to: 1.0)
}
}
transition.setFrame(view: self.listContainer, frame: listFrame)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,181 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
import ComponentDisplayAdapters
public final class StoryActionsComponent: Component {
public struct Item: Equatable {
public enum Kind {
case like
case share
}
public let kind: Kind
public let isActivated: Bool
public init(kind: Kind, isActivated: Bool) {
self.kind = kind
self.isActivated = isActivated
}
}
public let items: [Item]
public let action: (Item) -> Void
public init(
items: [Item],
action: @escaping (Item) -> Void
) {
self.items = items
self.action = action
}
public static func ==(lhs: StoryActionsComponent, rhs: StoryActionsComponent) -> Bool {
if lhs.items != rhs.items {
return false
}
return true
}
private final class ItemView: HighlightTrackingButton {
let action: (Item) -> Void
let maskBackgroundView = UIImageView()
let iconView: UIImageView
private var item: Item?
init(action: @escaping (Item) -> Void) {
self.action = action
self.iconView = UIImageView()
super.init(frame: CGRect())
self.addSubview(self.iconView)
self.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
let scale: CGFloat = highlighted ? 0.6 : 1.0
let transition = ComponentTransition(animation: .curve(duration: highlighted ? 0.5 : 0.3, curve: .spring))
transition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(scale, scale, 1.0))
transition.setScale(view: self.maskBackgroundView, scale: scale)
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let item = self.item else {
return
}
self.action(item)
}
func update(item: Item, size: CGSize, transition: ComponentTransition) {
if self.item == item {
return
}
self.item = item
switch item.kind {
case .like:
self.iconView.image = UIImage(bundleImageName: "Media Gallery/InlineLike")?.withRenderingMode(.alwaysTemplate)
case .share:
self.iconView.image = UIImage(bundleImageName: "Media Gallery/InlineShare")?.withRenderingMode(.alwaysTemplate)
}
self.iconView.tintColor = item.isActivated ? UIColor(rgb: 0xFF6F66) : UIColor.white
if let image = self.iconView.image {
transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) * 0.5), y: floor((size.height - image.size.height) * 0.5)), size: image.size))
}
}
}
public final class View: UIView {
private let backgroundView: BlurredBackgroundView
private let backgroundMaskView: UIView
private var itemViews: [Item.Kind: ItemView] = [:]
private var component: StoryActionsComponent?
private weak var componentState: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: nil, enableBlur: true)
self.backgroundMaskView = UIView()
self.backgroundView.mask = self.backgroundMaskView
super.init(frame: frame)
self.addSubview(self.backgroundView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryActionsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.componentState = state
var contentHeight: CGFloat = 0.0
var validIds: [Item.Kind] = []
for item in component.items {
validIds.append(item.kind)
let itemView: ItemView
var itemTransition = transition
if let current = self.itemViews[item.kind] {
itemView = current
} else {
itemTransition = .immediate
itemView = ItemView(action: { [weak self] item in
self?.component?.action(item)
})
self.itemViews[item.kind] = itemView
self.addSubview(itemView)
itemView.maskBackgroundView.image = generateFilledCircleImage(diameter: 44.0, color: .white)
self.backgroundMaskView.addSubview(itemView.maskBackgroundView)
}
if !contentHeight.isZero {
contentHeight += 10.0
}
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: 44.0, height: 44.0))
itemView.update(item: item, size: itemFrame.size, transition: itemTransition)
itemTransition.setFrame(view: itemView, frame: itemFrame)
itemTransition.setPosition(view: itemView.maskBackgroundView, position: itemFrame.center)
itemTransition.setBounds(view: itemView.maskBackgroundView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
contentHeight += itemFrame.height
}
let contentSize = CGSize(width: 44.0, height: contentHeight)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: contentSize))
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.3), transition: .immediate)
self.backgroundView.update(size: contentSize, transition: transition.containedViewLayoutTransition)
return contentSize
}
}
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,364 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AccountContext
import TelegramCore
import TelegramStringFormatting
import MultilineTextComponent
import TelegramPresentationData
import AvatarNode
import PeerNameTextComponent
final class StoryAuthorInfoComponent: Component {
struct Counters: Equatable {
var position: Int
var totalCount: Int
}
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let isEmbeddedInCamera: Bool
let peer: EnginePeer?
let forwardInfo: EngineStoryItem.ForwardInfo?
let author: EnginePeer?
let timestamp: Int32
let counters: Counters?
let isEdited: Bool
let isLiveStream: Bool
let customSubtitle: String?
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, isEmbeddedInCamera: Bool, peer: EnginePeer?, forwardInfo: EngineStoryItem.ForwardInfo?, author: EnginePeer?, timestamp: Int32, counters: Counters?, isEdited: Bool, isLiveStream: Bool, customSubtitle: String?) {
self.context = context
self.theme = theme
self.strings = strings
self.isEmbeddedInCamera = isEmbeddedInCamera
self.peer = peer
self.forwardInfo = forwardInfo
self.author = author
self.timestamp = timestamp
self.counters = counters
self.isEdited = isEdited
self.isLiveStream = isLiveStream
self.customSubtitle = customSubtitle
}
static func ==(lhs: StoryAuthorInfoComponent, rhs: StoryAuthorInfoComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.isEmbeddedInCamera != rhs.isEmbeddedInCamera {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.forwardInfo != rhs.forwardInfo {
return false
}
if lhs.author != rhs.author {
return false
}
if lhs.timestamp != rhs.timestamp {
return false
}
if lhs.counters != rhs.counters {
return false
}
if lhs.isEdited != rhs.isEdited {
return false
}
if lhs.isLiveStream != rhs.isLiveStream {
return false
}
if lhs.customSubtitle != rhs.customSubtitle {
return false
}
return true
}
final class View: UIView {
private let title = ComponentView<Empty>()
private var repostIconView: UIImageView?
private var avatarNode: AvatarNode?
private let subtitle = ComponentView<Empty>()
private var liveBadgeView: UIImageView?
private var counterLabel: ComponentView<Empty>?
private var component: StoryAuthorInfoComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryAuthorInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let size = availableSize
let spacing: CGFloat = 0.0
let leftInset: CGFloat = 54.0
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 })
let title: String
if component.peer?.id == component.context.account.peerId, !component.isEmbeddedInCamera {
title = component.strings.Story_HeaderYourStory
} else {
if let _ = component.counters {
title = component.peer?.compactDisplayTitle ?? ""
} else {
title = component.peer?.debugDisplayTitle ?? ""
}
}
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
let titleColor = UIColor.white
let subtitleColor = UIColor(white: 1.0, alpha: 0.8)
let subtitle: NSAttributedString
let subtitleTruncationType: CTLineTruncationType
if let customSubtitle = component.customSubtitle {
subtitle = NSAttributedString(string: customSubtitle, font: Font.medium(11.0), textColor: titleColor)
subtitleTruncationType = .end
} else if let forwardInfo = component.forwardInfo {
let authorName: String
switch forwardInfo {
case let .known(peer, _, _):
authorName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
case let .unknown(name, _):
authorName = name
}
let timeString = stringForStoryActivityTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, preciseTime: true, relativeTimestamp: component.timestamp, relativeTo: timestamp, short: true)
let combinedString = NSMutableAttributedString()
combinedString.append(NSAttributedString(string: authorName, font: Font.medium(11.0), textColor: titleColor))
if timeString.count < 6 {
combinedString.append(NSAttributedString(string: "\(timeString)", font: Font.regular(11.0), textColor: subtitleColor))
}
subtitle = combinedString
subtitleTruncationType = .middle
} else if let author = component.author {
let authorName = author.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
let combinedString = NSMutableAttributedString()
combinedString.append(NSAttributedString(string: authorName, font: Font.medium(11.0), textColor: titleColor))
if component.timestamp != 0 {
let timeString = stringForStoryActivityTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, preciseTime: true, relativeTimestamp: component.timestamp, relativeTo: timestamp, short: true)
if timeString.count < 6 {
combinedString.append(NSAttributedString(string: "\(timeString)", font: Font.regular(11.0), textColor: subtitleColor))
}
}
if component.isEdited {
combinedString.append(NSAttributedString(string: "\(component.strings.Story_HeaderEdited)", font: Font.regular(11.0), textColor: subtitleColor))
}
subtitle = combinedString
subtitleTruncationType = .middle
} else if component.timestamp != 0 {
var subtitleString = stringForStoryActivityTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, preciseTime: true, relativeTimestamp: component.timestamp, relativeTo: timestamp)
if component.isEdited {
subtitleString.append("")
subtitleString.append(component.strings.Story_HeaderEdited)
}
subtitle = NSAttributedString(string: subtitleString, font: Font.regular(11.0), textColor: subtitleColor)
subtitleTruncationType = .end
} else {
var subtitleString = ""
if component.isEdited {
subtitleString.append(component.strings.Story_HeaderEdited)
}
subtitle = NSAttributedString(string: subtitleString, font: Font.regular(11.0), textColor: subtitleColor)
subtitleTruncationType = .end
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(PeerNameTextComponent(
context: component.context,
peer: component.peer,
text: .custom(title),
font: Font.medium(14.0),
textColor: .white,
iconBackgroundColor: component.theme.list.itemCheckColors.fillColor,
iconForegroundColor: component.theme.list.itemCheckColors.foregroundColor,
strings: component.strings
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset, height: availableSize.height)
)
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(subtitle),
truncationType: subtitleTruncationType,
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset, height: availableSize.height)
)
var contentHeight: CGFloat = titleSize.height
if subtitle.length != 0 {
contentHeight += spacing + subtitleSize.height
}
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: 2.0 + floor((availableSize.height - contentHeight) * 0.5)), size: titleSize)
var subtitleOffset: CGFloat = 0.0
if let _ = component.forwardInfo {
let iconView: UIImageView
if let current = self.repostIconView {
iconView = current
} else {
iconView = UIImageView(image: UIImage(bundleImageName: "Stories/HeaderRepost")?.withRenderingMode(.alwaysTemplate))
iconView.tintColor = .white
self.addSubview(iconView)
self.repostIconView = iconView
}
let iconSize = CGSize(width: 13.0, height: 13.0)
let iconFrame = CGRect(origin: CGPoint(x: leftInset + subtitleOffset - 2.0 + UIScreenPixel, y: titleFrame.minY + contentHeight - iconSize.height + 1.0), size: iconSize)
transition.setFrame(view: iconView, frame: iconFrame)
subtitleOffset += iconSize.width + 1.0
} else if let repostIconView = self.repostIconView {
self.repostIconView = nil
repostIconView.removeFromSuperview()
}
var authorPeer: EnginePeer?
if let forwardInfo = component.forwardInfo, case let .known(peer, _, _) = forwardInfo {
authorPeer = peer
} else if let author = component.author {
authorPeer = author
}
if let peer = authorPeer {
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0))
self.addSubview(avatarNode.view)
self.avatarNode = avatarNode
}
let avatarSize = CGSize(width: 16.0, height: 16.0)
let theme = component.context.sharedContext.currentPresentationData.with { $0 }.theme
avatarNode.setPeer(context: component.context, theme: theme, peer: peer, synchronousLoad: true, displayDimensions: avatarSize)
let avatarFrame = CGRect(origin: CGPoint(x: leftInset + subtitleOffset, y: titleFrame.minY + contentHeight - avatarSize.height + 3.0 - UIScreenPixel), size: avatarSize)
avatarNode.frame = avatarFrame
subtitleOffset += avatarSize.width + 4.0
} else if let avatarNode = self.avatarNode {
self.avatarNode = nil
avatarNode.view.removeFromSuperview()
}
var subtitleFrame = CGRect(origin: CGPoint(x: leftInset + subtitleOffset, y: titleFrame.maxY + spacing + UIScreenPixel), size: subtitleSize)
if component.isLiveStream {
let liveBadgeView: UIImageView
if let current = self.liveBadgeView {
liveBadgeView = current
} else {
liveBadgeView = UIImageView()
self.liveBadgeView = liveBadgeView
self.addSubview(liveBadgeView)
let liveString = NSAttributedString(string: component.strings.Story_LiveBadge, font: Font.semibold(10.0), textColor: .white)
let liveStringBounds = liveString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
let liveBadgeSize = CGSize(width: ceil(liveStringBounds.width) + 3.0 * 2.0, height: ceil(liveStringBounds.height) + 1.0 * 2.0)
liveBadgeView.image = generateImage(liveBadgeSize, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: 0xFF2D55).cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.height * 0.5).cgPath)
context.fillPath()
liveString.draw(at: CGPoint(x: floorToScreenPixels((size.width - liveStringBounds.width) * 0.5), y: floorToScreenPixels((size.height - liveStringBounds.height) * 0.5)))
})
}
if let image = liveBadgeView.image {
let liveBadgeFrame = CGRect(origin: CGPoint(x: subtitleFrame.minX, y: subtitleFrame.minY), size: image.size)
liveBadgeView.frame = liveBadgeFrame
subtitleFrame.origin.x += image.size.width + 3.0
}
} else if let liveBadgeView = self.liveBadgeView {
self.liveBadgeView = nil
liveBadgeView.removeFromSuperview()
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
subtitleView.isUserInteractionEnabled = false
self.addSubview(subtitleView)
}
transition.setFrame(view: subtitleView, frame: subtitleFrame)
}
let countersSpacing: CGFloat = 5.0
if let counters = component.counters {
let counterLabel: ComponentView<Empty>
if let current = self.counterLabel {
counterLabel = current
} else {
counterLabel = ComponentView()
self.counterLabel = counterLabel
}
let counterSize = counterLabel.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "\(counters.position + 1)/\(counters.totalCount)", font: Font.regular(11.0), textColor: UIColor(white: 1.0, alpha: 0.43))),
truncationType: .end,
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: max(1.0, availableSize.width - titleSize.width - countersSpacing), height: 100.0)
)
if let counterLabelView = counterLabel.view {
if counterLabelView.superview == nil {
self.addSubview(counterLabelView)
}
counterLabelView.frame = CGRect(origin: CGPoint(x: titleFrame.maxX + countersSpacing, y: titleFrame.minY + 1.0 + floorToScreenPixels((titleFrame.height - counterSize.height) * 0.5)), size: counterSize)
}
} else if let counterLabel = self.counterLabel {
self.counterLabel = nil
counterLabel.view?.removeFromSuperview()
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,95 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AccountContext
import TelegramCore
import AsyncDisplayKit
import AvatarNode
final class StoryAvatarInfoComponent: Component {
let context: AccountContext
let peer: EnginePeer
let isLiveStream: Bool
init(context: AccountContext, peer: EnginePeer, isLiveStream: Bool) {
self.context = context
self.peer = peer
self.isLiveStream = isLiveStream
}
static func ==(lhs: StoryAvatarInfoComponent, rhs: StoryAvatarInfoComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.isLiveStream != rhs.isLiveStream {
return false
}
return true
}
final class View: UIView {
private let avatarNode: AvatarNode
private var component: StoryAvatarInfoComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 18.0))
super.init(frame: frame)
self.addSubnode(self.avatarNode)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryAvatarInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let size = CGSize(width: 32.0, height: 32.0)
self.avatarNode.frame = CGRect(origin: CGPoint(), size: size)
self.avatarNode.setPeer(
context: component.context,
theme: component.context.sharedContext.currentPresentationData.with({ $0 }).theme,
peer: component.peer,
synchronousLoad: true
)
self.avatarNode.setStoryStats(
storyStats: component.isLiveStream ? AvatarNode.StoryStats(
totalCount: 1,
unseenCount: 1,
hasUnseenCloseFriendsItems: false,
hasLiveItems: true
) : nil,
presentationParams: AvatarNode.StoryPresentationParams(
colors: AvatarNode.Colors(
unseenColors: [.white],
unseenCloseFriendsColors: [.white],
seenColors: [.white]
),
lineWidth: 1.33,
inactiveLineWidth: 1.33
),
transition: .immediate
)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,328 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Postbox
import TelegramPresentationData
public final class StoryContentItem: Equatable {
public final class ExternalState {
public init() {
}
}
public enum AudioMode {
case ambient
case on
case off
}
public struct DayCounters: Equatable {
public var position: Int
public var totalCount: Int
public init(position: Int, totalCount: Int) {
self.position = position
self.totalCount = totalCount
}
}
public final class SharedState {
public var replyDrafts: [StoryId: NSAttributedString] = [:]
public var baseRate: Double = 1.0
public init() {
}
}
public enum ProgressMode {
case play
case pause
case blurred
}
open class View: UIView {
open func setProgressMode(mode: StoryContentItem.ProgressMode, isCentral: Bool) {
}
open func rewind() {
}
open func leaveAmbientMode() {
}
open func enterAmbientMode(ambient: Bool) {
}
open func setBaseRate(_ baseRate: Double) {
}
open var videoPlaybackPosition: Double? {
return nil
}
}
public final class Environment: Equatable {
public let externalState: ExternalState
public let sharedState: SharedState
public let theme: PresentationTheme
public let containerInsets: UIEdgeInsets
public let presentationProgressUpdated: (Double, Bool, Bool) -> Void
public let customItemSubtitleUpdated: () -> Void
public let markAsSeen: (StoryId) -> Void
public init(
externalState: ExternalState,
sharedState: SharedState,
theme: PresentationTheme,
containerInsets: UIEdgeInsets,
presentationProgressUpdated: @escaping (Double, Bool, Bool) -> Void,
customItemSubtitleUpdated: @escaping () -> Void,
markAsSeen: @escaping (StoryId) -> Void
) {
self.externalState = externalState
self.sharedState = sharedState
self.theme = theme
self.containerInsets = containerInsets
self.presentationProgressUpdated = presentationProgressUpdated
self.customItemSubtitleUpdated = customItemSubtitleUpdated
self.markAsSeen = markAsSeen
}
public static func ==(lhs: Environment, rhs: Environment) -> Bool {
if lhs.externalState !== rhs.externalState {
return false
}
if lhs.sharedState !== rhs.sharedState {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.containerInsets != rhs.containerInsets {
return false
}
return true
}
}
public let id: StoryId
public let position: Int?
public let dayCounters: DayCounters?
public let peerId: EnginePeer.Id?
public let storyItem: EngineStoryItem
public let entityFiles: [EngineMedia.Id: TelegramMediaFile]
public let itemPeer: EnginePeer?
public init(
id: StoryId,
position: Int?,
dayCounters: DayCounters?,
peerId: EnginePeer.Id?,
storyItem: EngineStoryItem,
entityFiles: [EngineMedia.Id: TelegramMediaFile],
itemPeer: EnginePeer?
) {
self.id = id
self.position = position
self.dayCounters = dayCounters
self.peerId = peerId
self.storyItem = storyItem
self.entityFiles = entityFiles
self.itemPeer = itemPeer
}
public static func ==(lhs: StoryContentItem, rhs: StoryContentItem) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.position != rhs.position {
return false
}
if lhs.dayCounters != rhs.dayCounters {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.storyItem != rhs.storyItem {
return false
}
if lhs.entityFiles != rhs.entityFiles {
return false
}
if lhs.itemPeer != rhs.itemPeer {
return false
}
return true
}
}
public final class StoryContentContextState {
public final class AdditionalPeerData: Equatable {
public let isMuted: Bool
public let areVoiceMessagesAvailable: Bool
public let presence: EnginePeer.Presence?
public let canViewStats: Bool
public let isPremiumRequiredForMessaging: Bool
public let preferHighQualityStories: Bool
public let boostsToUnrestrict: Int32?
public let appliedBoosts: Int32?
public let sendPaidMessageStars: StarsAmount?
public init(
isMuted: Bool,
areVoiceMessagesAvailable: Bool,
presence: EnginePeer.Presence?,
canViewStats: Bool,
isPremiumRequiredForMessaging: Bool,
preferHighQualityStories: Bool,
boostsToUnrestrict: Int32?,
appliedBoosts: Int32?,
sendPaidMessageStars: StarsAmount?
) {
self.isMuted = isMuted
self.areVoiceMessagesAvailable = areVoiceMessagesAvailable
self.presence = presence
self.canViewStats = canViewStats
self.isPremiumRequiredForMessaging = isPremiumRequiredForMessaging
self.preferHighQualityStories = preferHighQualityStories
self.boostsToUnrestrict = boostsToUnrestrict
self.appliedBoosts = appliedBoosts
self.sendPaidMessageStars = sendPaidMessageStars
}
public static func == (lhs: StoryContentContextState.AdditionalPeerData, rhs: StoryContentContextState.AdditionalPeerData) -> Bool {
if lhs.isMuted != rhs.isMuted {
return false
}
if lhs.areVoiceMessagesAvailable != rhs.areVoiceMessagesAvailable {
return false
}
if lhs.presence != rhs.presence {
return false
}
if lhs.canViewStats != rhs.canViewStats {
return false
}
if lhs.isPremiumRequiredForMessaging != rhs.isPremiumRequiredForMessaging {
return false
}
if lhs.preferHighQualityStories != rhs.preferHighQualityStories {
return false
}
if lhs.boostsToUnrestrict != rhs.boostsToUnrestrict {
return false
}
if lhs.appliedBoosts != rhs.appliedBoosts {
return false
}
if lhs.sendPaidMessageStars != rhs.sendPaidMessageStars {
return false
}
return true
}
}
public final class FocusedSlice: Equatable {
public let peer: EnginePeer
public let additionalPeerData: AdditionalPeerData
public let item: StoryContentItem
public let totalCount: Int
public let previousItemId: StoryId?
public let nextItemId: StoryId?
public let allItems: [StoryContentItem]
public let forwardInfoStories: [StoryId: Promise<EngineStoryItem?>]
var effectivePeer: EnginePeer {
return self.item.itemPeer ?? self.peer
}
public init(
peer: EnginePeer,
additionalPeerData: AdditionalPeerData,
item: StoryContentItem,
totalCount: Int,
previousItemId: StoryId?,
nextItemId: StoryId?,
allItems: [StoryContentItem],
forwardInfoStories: [StoryId: Promise<EngineStoryItem?>]
) {
self.peer = peer
self.additionalPeerData = additionalPeerData
self.item = item
self.totalCount = totalCount
self.previousItemId = previousItemId
self.nextItemId = nextItemId
self.allItems = allItems
self.forwardInfoStories = forwardInfoStories
}
public static func ==(lhs: FocusedSlice, rhs: FocusedSlice) -> Bool {
if lhs.peer != rhs.peer {
return false
}
if lhs.additionalPeerData != rhs.additionalPeerData {
return false
}
if lhs.item != rhs.item {
return false
}
if lhs.totalCount != rhs.totalCount {
return false
}
if lhs.previousItemId != rhs.previousItemId {
return false
}
if lhs.nextItemId != rhs.nextItemId {
return false
}
if lhs.allItems != rhs.allItems {
return false
}
return true
}
}
public let slice: FocusedSlice?
public let previousSlice: FocusedSlice?
public let nextSlice: FocusedSlice?
public init(
slice: FocusedSlice?,
previousSlice: FocusedSlice?,
nextSlice: FocusedSlice?
) {
self.slice = slice
self.previousSlice = previousSlice
self.nextSlice = nextSlice
}
}
public enum StoryContentContextNavigation {
public enum ItemDirection {
case previous
case next
case id(StoryId)
}
public enum PeerDirection {
case previous
case next
}
case item(ItemDirection)
case peer(PeerDirection)
}
public protocol StoryContentContext: AnyObject {
var stateValue: StoryContentContextState? { get }
var state: Signal<StoryContentContextState, NoError> { get }
var updated: Signal<Void, NoError> { get }
func resetSideStates()
func navigate(navigation: StoryContentContextNavigation)
func markAsSeen(id: StoryId)
}
@@ -0,0 +1,872 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import AccountContext
import Postbox
import TelegramCore
import TelegramPresentationData
import SwiftSignalKit
import TelegramCallsUI
import AsyncListComponent
import AvatarNode
import MultilineTextWithEntitiesComponent
import GlassBackgroundComponent
import MultilineTextComponent
import ContextUI
import StarsParticleEffect
import StoryLiveChatMessageComponent
import AdminUserActionsSheet
final class StoryContentLiveChatComponent: Component {
final class External {
fileprivate(set) var isEmpty: Bool = false
fileprivate(set) var hasUnseenMessages: Bool = false
init() {
}
}
let external: External
let context: AccountContext
let strings: PresentationStrings
let theme: PresentationTheme
let call: PresentationGroupCall
let storyPeerId: EnginePeer.Id
let canManageMessagesFromPeers: Set<EnginePeer.Id>
let insets: UIEdgeInsets
let isEmbeddedInCamera: Bool
let minPaidStars: Int?
let controller: () -> ViewController?
init(
external: External,
context: AccountContext,
strings: PresentationStrings,
theme: PresentationTheme,
call: PresentationGroupCall,
storyPeerId: EnginePeer.Id,
canManageMessagesFromPeers: Set<EnginePeer.Id>,
insets: UIEdgeInsets,
isEmbeddedInCamera: Bool,
minPaidStars: Int?,
controller: @escaping () -> ViewController?
) {
self.external = external
self.context = context
self.strings = strings
self.theme = theme
self.call = call
self.storyPeerId = storyPeerId
self.canManageMessagesFromPeers = canManageMessagesFromPeers
self.insets = insets
self.isEmbeddedInCamera = isEmbeddedInCamera
self.minPaidStars = minPaidStars
self.controller = controller
}
static func ==(lhs: StoryContentLiveChatComponent, rhs: StoryContentLiveChatComponent) -> Bool {
if lhs.external !== rhs.external {
return false
}
if lhs.context !== rhs.context {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.call !== rhs.call {
return false
}
if lhs.storyPeerId != rhs.storyPeerId {
return false
}
if lhs.canManageMessagesFromPeers != rhs.canManageMessagesFromPeers {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.isEmbeddedInCamera != rhs.isEmbeddedInCamera {
return false
}
if lhs.minPaidStars != rhs.minPaidStars {
return false
}
return true
}
struct StarStats {
var myStars: Int64
var pendingMyStars: Int64
var totalStars: Int64
var topItems: [GroupCallMessagesContext.TopStarsItem]
init(myStars: Int64, pendingMyStars: Int64, totalStars: Int64, topItems: [GroupCallMessagesContext.TopStarsItem]) {
self.myStars = myStars
self.pendingMyStars = pendingMyStars
self.totalStars = totalStars
self.topItems = topItems
}
}
struct Info {
var starStats: StarStats?
var isChatEmpty: Bool
var isChatExpanded: Bool
init(starStats: StarStats?, isChatEmpty: Bool, isChatExpanded: Bool) {
self.starStats = starStats
self.isChatEmpty = isChatEmpty
self.isChatExpanded = isChatExpanded
}
}
final class View: UIView {
private let listContainer: UIView
private let listMaskContainer: UIView
private let maskGradientView: UIImageView
private let pinnedBar = ComponentView<Empty>()
private let listState = AsyncListComponent.ExternalState()
private var isScrollToBottomScheduled: Bool = false
private let list = ComponentView<Empty>()
private let listShadowView: UIView
private var reactionStreamView: LiveChatReactionStreamView?
private var component: StoryContentLiveChatComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var messagesState: GroupCallMessagesContext.State?
private var stateDisposable: Disposable?
private var currentListIsEmpty: Bool = true
private var isMessageContextMenuOpen: Bool = false
private var isChatExpanded: Bool = false
public var currentInfo: Info {
var starStats: StoryContentLiveChatComponent.StarStats?
var isChatEmpty = true
if let messagesState = self.messagesState {
isChatEmpty = messagesState.messages.isEmpty
var myStars: Int64 = 0
if let item = messagesState.topStars.first(where: { $0.isMy }) {
myStars = item.amount
}
starStats = StoryContentLiveChatComponent.StarStats(myStars: myStars + messagesState.pendingMyStars, pendingMyStars: messagesState.pendingMyStars, totalStars: messagesState.totalStars + messagesState.pendingMyStars, topItems: messagesState.topStars)
}
return Info(
starStats: starStats,
isChatEmpty: isChatEmpty,
isChatExpanded: self.isChatExpanded
)
}
override init(frame: CGRect) {
self.listContainer = UIView()
self.listMaskContainer = UIView()
self.listContainer.mask = self.listMaskContainer
self.maskGradientView = UIImageView()
do {
let height: CGFloat = 40.0
let baseGradientAlpha: CGFloat = 1.0
let numSteps = 8
let firstStep = 0
let firstLocation = 0.0
let colors = (0 ..< numSteps).map { i -> UIColor in
if i < firstStep {
return UIColor(white: 1.0, alpha: 1.0)
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
let value: CGFloat = 1.0 - bezierPoint(0.42, 0.0, 0.58, 1.0, step)
return UIColor(white: 1.0, alpha: baseGradientAlpha * value)
}
}
let locations = (0 ..< numSteps).map { i -> CGFloat in
if i < firstStep {
return 0.0
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
return (firstLocation + (1.0 - firstLocation) * step)
}
}
let image = generateGradientImage(size: CGSize(width: 8.0, height: height), colors: colors.reversed(), locations: locations.reversed().map { 1.0 - $0 })!
self.maskGradientView.image = generateImage(CGSize(width: image.size.width, height: image.size.height * 2.0), rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
image.draw(in: CGRect(origin: CGPoint(), size: image.size))
let bottomFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - image.size.height), size: image.size)
context.translateBy(x: bottomFrame.midX, y: bottomFrame.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -bottomFrame.midX, y: -bottomFrame.midY)
image.draw(in: bottomFrame)
})!.stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(height - 1.0))
}
self.listMaskContainer.addSubview(self.maskGradientView)
self.listShadowView = UIView()
self.listShadowView.isUserInteractionEnabled = false
super.init(frame: frame)
self.addSubview(self.listShadowView)
self.addSubview(self.listContainer)
self.isChatExpanded = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.stateDisposable?.dispose()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
guard let result = super.hitTest(point, with: event) else {
return nil
}
if let listView = self.list.view as? AsyncListComponent.View, result.isDescendant(of: listView), let listComponent = listView.component {
let localPoint = self.convert(point, to: listView)
if localPoint.y > listView.bounds.height - listComponent.insets.bottom {
return nil
}
if let visibleItems = listView.visibleItems() {
var maxItemY: CGFloat?
for visibleItem in visibleItems {
if let maxItemYValue = maxItemY {
maxItemY = max(maxItemYValue, visibleItem.frame.maxY)
} else {
maxItemY = visibleItem.frame.maxY
}
}
if let maxItemY {
if self.convert(point, to: listView).y >= maxItemY {
return nil
}
} else {
return nil
}
} else {
return nil
}
}
if let component = self.component, component.isEmbeddedInCamera && result === self.listContainer {
return nil
}
return result
}
func toggleLiveChatExpanded() {
guard let component = self.component else {
return
}
self.isChatExpanded = !self.isChatExpanded
if self.isChatExpanded {
component.external.hasUnseenMessages = false
}
self.state?.updated(transition: .spring(duration: 0.4))
}
private func displayDeleteMessageConfirmation(id: GroupCallMessagesContext.Message.Id) {
guard let component = self.component else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
let actionSheet = ActionSheetController(presentationData: presentationData)
actionSheet.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: component.strings.Chat_DeleteMessagesConfirmation(1), color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self, let component = self.component, let call = component.call as? PresentationGroupCallImpl else {
return
}
call.deleteMessage(id: id, reportSpam: false)
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
component.controller()?.view.endEditing(true)
component.controller()?.present(actionSheet, in: .window(.root))
}
private func displayDeleteMessageAndBan(id: GroupCallMessagesContext.Message.Id) {
Task { @MainActor [weak self] in
guard let self, let component = self.component else {
return
}
guard let chatPeer = await component.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: component.storyPeerId)
).get() else {
return
}
guard let messagesState = self.messagesState, let message = messagesState.messages.first(where: { $0.id == id }) else {
return
}
if message.isFromAdmin {
self.displayDeleteMessageConfirmation(id: id)
return
}
guard let author = message.author else {
return
}
var totalCount = 0
for message in messagesState.messages {
if message.author?.id == author.id {
totalCount += 1
}
}
guard let controller = component.controller() else {
return
}
controller.push(AdminUserActionsSheet(
context: component.context,
chatPeer: chatPeer,
peers: [RenderedChannelParticipant(
participant: .member(
id: author.id,
invitedAt: 0,
adminInfo: nil,
banInfo: nil,
rank: nil,
subscriptionUntilDate: nil
),
peer: author._asPeer()
)],
mode: .liveStream(
messageCount: 1,
deleteAllMessageCount: totalCount,
completion: { [weak self] result in
guard let self, let component = self.component, let call = component.call as? PresentationGroupCallImpl else {
return
}
if result.deleteAll {
call.deleteAllMessages(authorId: author.id, reportSpam: result.reportSpam)
} else {
call.deleteMessage(id: id, reportSpam: result.reportSpam)
}
if result.ban {
if component.storyPeerId == component.context.account.peerId {
let _ = component.context.engine.privacy.requestUpdatePeerIsBlocked(peerId: author.id, isBlocked: true).startStandalone()
} else {
let _ = component.context.engine.peers.updateChannelMemberBannedRights(peerId: component.storyPeerId, memberId: author.id, rights: TelegramChatBannedRights(flags: .banReadMessages, untilDate: Int32.max)).startStandalone()
}
}
}
),
customTheme: defaultDarkColorPresentationTheme
))
}
}
@discardableResult private func scrollToMessage(id: GroupCallMessagesContext.Message.Id, highlight: Bool) -> Bool {
guard let messagesState = self.messagesState, let message = messagesState.messages.first(where: { $0.id == id }) else {
return false
}
self.listState.resetScrolling(id: AnyHashable(message.stableId))
self.state?.updated(transition: .spring(duration: 0.4), isLocal: true)
if highlight {
if let listView = self.list.view as? AsyncListComponent.View, let itemView = listView.visibleItemView(id: AnyHashable(message.stableId)) as? StoryLiveChatMessageComponent.View {
itemView.flashHighlight()
}
}
return true
}
private func openMessageContextMenu(id: GroupCallMessagesContext.Message.Id, isPinned: Bool, gesture: ContextGesture, sourceNode: ContextExtractedContentContainingNode) {
Task { @MainActor [weak self] in
guard let self else {
return
}
guard let component = self.component else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme)
if let listView = self.list.view as? AsyncListComponent.View {
listView.stopScrolling()
}
var items: [ContextMenuItem] = []
if !isPinned, let messagesState = self.messagesState, let message = messagesState.messages.first(where: { $0.id == id }), !message.text.isEmpty {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_ContextMenuCopy, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
guard let self else {
return
}
c?.dismiss(completion: { [weak self] in
guard let self else {
return
}
if let messagesState = self.messagesState, let message = messagesState.messages.first(where: { $0.id == id }) {
UIPasteboard.general.string = message.text
}
})
})))
}
let state = await (component.call.state |> take(1)).get()
var isAdmin = false
isAdmin = state.canManageCall
var canDelete = isAdmin
var isMyMessage = false
guard let messagesState = self.messagesState, let message = messagesState.messages.first(where: { $0.id == id }) else {
return
}
if message.author?.id == component.context.account.peerId {
isMyMessage = true
canDelete = true
}
if let author = message.author, component.canManageMessagesFromPeers.contains(author.id) {
isMyMessage = true
canDelete = true
}
var isMessageFromAdmin = false
if message.isFromAdmin {
isMessageFromAdmin = true
} else if message.author?.id == component.storyPeerId {
isMessageFromAdmin = true
}
if !isMyMessage, let author = message.author {
let openProfileString: String
if case .channel = author {
openProfileString = presentationData.strings.Conversation_ContextMenuOpenChannel
} else {
openProfileString = presentationData.strings.Conversation_ContextMenuOpenProfile
}
items.append(.action(ContextMenuActionItem(text: openProfileString, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
guard let self else {
return
}
c?.dismiss(completion: { [weak self] in
guard let self, let component = self.component else {
return
}
guard let controller = component.controller(), let navigationController = controller.navigationController as? NavigationController else {
return
}
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: component.context,
chatLocation: .peer(author),
keepStack: .always
))
})
})))
}
if canDelete {
items.append(.action(ContextMenuActionItem(text: presentationData.strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, _ in
guard let self else {
return
}
c?.dismiss(completion: { [weak self] in
guard let self else {
return
}
if isAdmin && !isMyMessage && !isMessageFromAdmin {
self.displayDeleteMessageAndBan(id: id)
} else {
self.displayDeleteMessageConfirmation(id: id)
}
})
})))
}
let contextController = ContextController(
presentationData: presentationData,
source: .extracted(ItemExtractedContentSource(
sourceNode: sourceNode,
containerView: self,
keepInPlace: false
)),
items: .single(ContextController.Items(content: .list(items))),
recognizer: nil,
gesture: gesture
)
contextController.dismissed = { [weak self] in
guard let self else {
return
}
self.isMessageContextMenuOpen = false
if !self.isUpdating {
self.state?.updated(transition: .easeInOut(duration: 0.2), isLocal: true)
}
}
self.isMessageContextMenuOpen = true
if !self.isUpdating {
self.state?.updated(transition: .easeInOut(duration: 0.2), isLocal: true)
}
component.controller()?.presentInGlobalOverlay(contextController)
}
}
func scheduleScrollLiveChatToBottom() {
self.isScrollToBottomScheduled = true
}
func update(component: StoryContentLiveChatComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
if self.component?.call !== component.call {
self.stateDisposable?.dispose()
if let call = component.call as? PresentationGroupCallImpl {
self.stateDisposable = (call.messagesState
|> deliverOnMainQueue).startStrict(next: { [weak self] state in
guard let self else {
return
}
var updateTransition: ComponentTransition = .easeInOut(duration: 0.2)
if self.messagesState == nil {
updateTransition = .immediate
}
if let component = self.component, let previousMessagesState = self.messagesState {
if !self.isChatExpanded {
var hasNewMessages = false
for message in state.messages {
if message.isIncoming {
if !previousMessagesState.messages.contains(where: { $0.id == message.id }) {
hasNewMessages = true
}
if let paidStars = message.paidStars, let author = message.author {
self.reactionStreamView?.add(peer: author, count: Int(paidStars))
}
}
}
if hasNewMessages {
component.external.hasUnseenMessages = true
}
}
for message in state.messages {
if message.isIncoming {
if !previousMessagesState.messages.contains(where: { $0.id == message.id }) {
if let paidStars = message.paidStars, let author = message.author {
self.reactionStreamView?.add(peer: author, count: Int(paidStars))
}
}
}
}
if state.pendingMyStars > previousMessagesState.pendingMyStars, let message = state.messages.first(where: { $0.paidStars != nil && !$0.isIncoming }), let peer = message.author {
self.reactionStreamView?.add(peer: peer, count: Int(state.pendingMyStars - previousMessagesState.pendingMyStars))
}
}
component.external.isEmpty = state.messages.isEmpty
self.messagesState = state
if !self.isUpdating {
self.state?.updated(transition: updateTransition)
}
})
}
}
self.component = component
self.state = state
if self.isChatExpanded {
component.external.hasUnseenMessages = false
}
let previousListIsEmpty = self.currentListIsEmpty
var listItems: [AnyComponentWithIdentity<Empty>] = []
var topMessageByPeerId: [EnginePeer.Id: GroupCallMessagesContext.Message] = [:]
if let messagesState = self.messagesState {
for message in messagesState.messages.reversed() {
let messageId = message.id
var topPlace = self.messagesState?.topStars.firstIndex(where: { $0.peerId != nil && $0.peerId == message.author?.id })
if let topPlaceValue = topPlace, topPlaceValue >= 3 {
topPlace = nil
}
listItems.append(AnyComponentWithIdentity(id: message.stableId, component: AnyComponent(StoryLiveChatMessageComponent(
context: component.context,
strings: component.strings,
theme: component.theme,
layout: StoryLiveChatMessageComponent.Layout(
isFlipped: true,
insets: UIEdgeInsets(top: 9.0, left: 24.0, bottom: 9.0, right: 20.0),
fitToWidth: false,
transparentBackground: true
),
message: message,
topPlace: topPlace,
contextGesture: { [weak self] gesture, sourceNode in
guard let self else {
return
}
self.openMessageContextMenu(id: messageId, isPinned: false, gesture: gesture, sourceNode: sourceNode)
}
))))
}
for message in messagesState.pinnedMessages.reversed() {
if let author = message.author, let paidStars = message.paidStars {
if let minPaidStars = component.minPaidStars {
if Int(paidStars) < minPaidStars {
continue
}
}
if let current = topMessageByPeerId[author.id] {
if let currentPaidStars = current.paidStars, currentPaidStars < paidStars {
topMessageByPeerId[author.id] = message
}
} else {
topMessageByPeerId[author.id] = message
}
}
}
}
let topMessages: [GroupCallMessagesContext.Message] = topMessageByPeerId.values.sorted(by: { lhs, rhs in
let lhsValue = lhs.paidStars ?? 0
let rhsValue = rhs.paidStars ?? 0
if lhsValue != rhsValue {
return lhsValue > rhsValue
}
return lhs.date > rhs.date
})
var topIndices: [EnginePeer.Id: Int] = [:]
if let messagesState = self.messagesState {
for topMessage in topMessages {
if let author = topMessage.author, topIndices[author.id] == nil {
if let index = messagesState.topStars.firstIndex(where: { $0.peerId != nil && $0.peerId == author.id }), index < 3 {
topIndices[author.id] = index
}
}
}
}
self.currentListIsEmpty = listItems.isEmpty
let pinnedBarSize = self.pinnedBar.update(
transition: transition,
component: AnyComponent(PinnedBarComponent(
context: component.context,
strings: component.strings,
theme: component.theme,
isExpanded: self.isChatExpanded,
messages: topMessages,
topIndices: topIndices,
action: { [weak self] message in
guard let self else {
return
}
self.isChatExpanded = true
if !self.scrollToMessage(id: message.id, highlight: true) {
if !self.isUpdating {
self.state?.updated(transition: .spring(duration: 0.4))
}
}
},
contextGesture: { [weak self] message, gesture, sourceNode in
guard let self else {
return
}
self.openMessageContextMenu(id: message.id, isPinned: true, gesture: gesture, sourceNode: sourceNode)
}
)),
environment: {},
containerSize: availableSize
)
let pinnedBarFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - component.insets.bottom - pinnedBarSize.height - 4.0), size: pinnedBarSize)
if let pinnedBarView = self.pinnedBar.view {
if pinnedBarView.superview == nil {
self.addSubview(pinnedBarView)
}
transition.setFrame(view: pinnedBarView, frame: pinnedBarFrame)
let pinnedBarAlpha: CGFloat
if self.isMessageContextMenuOpen {
pinnedBarAlpha = 0.25
} else {
pinnedBarAlpha = topMessages.isEmpty ? 0.0 : 1.0
}
transition.setAlpha(view: pinnedBarView, alpha: pinnedBarAlpha)
}
var listInsets = UIEdgeInsets(top: component.insets.bottom + 12.0, left: component.insets.right, bottom: component.insets.top + 8.0, right: component.insets.left)
if component.insets.bottom == 0.0 {
listInsets.bottom = floor(availableSize.height * 0.5)
}
if !topMessages.isEmpty {
listInsets.top = availableSize.height - pinnedBarFrame.minY
}
listInsets.top += 1.0
var listTransition = transition
if previousListIsEmpty != self.currentListIsEmpty {
listTransition = listTransition.withAnimation(.none)
}
if self.isScrollToBottomScheduled {
self.isScrollToBottomScheduled = false
if let firstItem = listItems.first {
self.listState.resetScrolling(id: firstItem.id)
}
}
let _ = self.list.update(
transition: listTransition,
component: AnyComponent(AsyncListComponent(
externalState: self.listState,
items: listItems,
itemSetId: AnyHashable(0),
direction: .vertical,
insets: listInsets
)),
environment: {},
containerSize: availableSize
)
let listFrame = CGRect(origin: CGPoint(), size: availableSize)
if let listView = self.list.view as? AsyncListComponent.View {
if listView.superview == nil {
listView.transform = CGAffineTransformMakeRotation(CGFloat.pi)
self.listContainer.addSubview(listView)
}
transition.setPosition(view: listView, position: listFrame.offsetBy(dx: 0.0, dy: self.isChatExpanded ? 0.0 : listFrame.height).center)
transition.setBounds(view: listView, bounds: CGRect(origin: CGPoint(), size: listFrame.size))
let listAlpha: CGFloat
if self.isMessageContextMenuOpen {
listAlpha = 0.25
} else {
listAlpha = listItems.isEmpty ? 0.0 : 1.0
}
if previousListIsEmpty && !listItems.isEmpty && !alphaTransition.animation.isImmediate {
listView.alpha = 1.0
var delay: Double = 0.0
let delayIncrement: Double = 0.014
for itemView in listView.visibleItemViews() {
if let itemView = itemView as? StoryLiveChatMessageComponent.View {
itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: delay)
itemView.layer.animateScale(from: 0.95, to: 1.0, duration: 0.2, delay: delay)
delay += delayIncrement
}
}
} else {
alphaTransition.setAlpha(view: listView, alpha: listAlpha)
}
}
transition.setFrame(view: self.listContainer, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setFrame(view: self.listMaskContainer, frame: CGRect(origin: CGPoint(), size: availableSize))
let maskTopInset: CGFloat = listInsets.bottom - 20.0
let maskBottomInset: CGFloat = listInsets.top - 26.0
transition.setFrame(view: self.maskGradientView, frame: CGRect(origin: CGPoint(x: 0.0, y: maskTopInset), size: CGSize(width: availableSize.width, height: max(0.0, availableSize.height - maskTopInset - maskBottomInset))))
transition.setFrame(view: self.listShadowView, frame: CGRect(origin: CGPoint(), size: availableSize))
self.listShadowView.backgroundColor = UIColor(white: 0.0, alpha: 0.3)
transition.setAlpha(view: self.listShadowView, alpha: self.isChatExpanded ? 1.0 : 0.0)
let reactionStreamView: LiveChatReactionStreamView
if let current = self.reactionStreamView {
reactionStreamView = current
} else {
reactionStreamView = LiveChatReactionStreamView(context: component.context)
self.reactionStreamView = reactionStreamView
self.addSubview(reactionStreamView)
}
reactionStreamView.update(size: availableSize, sourcePoint: CGPoint(x: availableSize.width, y: availableSize.height), transition: transition)
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class ItemExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool
let ignoreContentTouches: Bool = true
let blurBackground: Bool = false
let adjustContentForSideInset: Bool = true
private let sourceNode: ContextExtractedContentContainingNode
private weak var containerView: UIView?
init(sourceNode: ContextExtractedContentContainingNode, containerView: UIView, keepInPlace: Bool) {
self.sourceNode = sourceNode
self.containerView = containerView
self.keepInPlace = keepInPlace
}
func takeView() -> ContextControllerTakeViewInfo? {
var contentArea: CGRect?
if let containerView = self.containerView {
contentArea = containerView.convert(containerView.bounds, to: nil)
}
return ContextControllerTakeViewInfo(
containingItem: .node(self.sourceNode),
contentAreaInScreenSpace: contentArea ?? UIScreen.main.bounds
)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,450 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import MultilineTextComponent
import BalancedTextComponent
import LottieComponent
final class StoryInteractionGuideComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let action: () -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
action: @escaping () -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.action = action
}
static func ==(lhs: StoryInteractionGuideComponent, rhs: StoryInteractionGuideComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private var component: StoryInteractionGuideComponent?
private weak var state: EmptyComponentState?
private let effectView: UIVisualEffectView
private let containerView = UIView()
private let titleLabel = ComponentView<Empty>()
private let descriptionLabel = ComponentView<Empty>()
private let guideItems = ComponentView<Empty>()
private let proceedButton = ComponentView<Empty>()
var currentIndex = 0
override init(frame: CGRect) {
self.effectView = UIVisualEffectView(effect: nil)
super.init(frame: frame)
self.addSubview(self.effectView)
self.addSubview(self.containerView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleTap() {
if let component = self.component {
component.action()
}
}
var didAnimateOut = false
func animateIn() {
self.didAnimateOut = false
UIView.animate(withDuration: 0.2) {
self.effectView.effect = UIBlurEffect(style: .dark)
}
self.containerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.containerView.layer.animateScale(from: 0.85, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: @escaping () -> Void) {
guard !self.didAnimateOut else {
return
}
self.didAnimateOut = true
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
completion()
})
self.containerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
self.containerView.layer.animateScale(from: 1.0, to: 1.1, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
}
func update(component: StoryInteractionGuideComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let strings = component.strings
let sideInset: CGFloat = min(48.0, floor(availableSize.width * 0.1))
let items: [AnyComponentWithIdentity<Empty>] = [
AnyComponentWithIdentity(
id: "forward",
component: AnyComponent(
GuideItemComponent(
context: component.context,
title: strings.Story_Guide_ForwardTitle,
text: strings.Story_Guide_ForwardDescription,
animationName: "story_forward",
isPlaying: self.currentIndex == 0,
playbackCompleted: { [weak self] in
guard let self else {
return
}
self.currentIndex = 1
self.state?.updated(transition: .easeInOut(duration: 0.3))
}
)
)
),
AnyComponentWithIdentity(
id: "pause",
component: AnyComponent(
GuideItemComponent(
context: component.context,
title: strings.Story_Guide_PauseTitle,
text: strings.Story_Guide_PauseDescription,
animationName: "story_pause",
isPlaying: self.currentIndex == 1,
playbackCompleted: { [weak self] in
guard let self else {
return
}
self.currentIndex = 2
self.state?.updated(transition: .easeInOut(duration: 0.3))
}
)
)
),
AnyComponentWithIdentity(
id: "back",
component: AnyComponent(
GuideItemComponent(
context: component.context,
title: strings.Story_Guide_BackTitle,
text: strings.Story_Guide_BackDescription,
animationName: "story_back",
isPlaying: self.currentIndex == 2,
playbackCompleted: { [weak self] in
guard let self else {
return
}
self.currentIndex = 3
self.state?.updated(transition: .easeInOut(duration: 0.3))
}
)
)
),
AnyComponentWithIdentity(
id: "move",
component: AnyComponent(
GuideItemComponent(
context: component.context,
title: strings.Story_Guide_MoveTitle,
text: strings.Story_Guide_MoveDescription,
animationName: "story_move",
isPlaying: self.currentIndex == 3,
playbackCompleted: { [weak self] in
guard let self else {
return
}
self.currentIndex = 0
self.state?.updated(transition: .easeInOut(duration: 0.3))
}
)
)
)
]
let itemsSize = self.guideItems.update(
transition: transition,
component: AnyComponent(List(items)),
environment: {},
containerSize: CGSize(width: min(500.0, availableSize.width - sideInset * 2.0), height: availableSize.height)
)
let textSpacing: CGFloat = 7.0
let itemsSpacing: CGFloat = 36.0
let buttonSpacing: CGFloat = 50.0
let titleSize = self.titleLabel.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Story_Guide_Title, font: Font.semibold(20.0), textColor: .white, paragraphAlignment: .center)))),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
let textSize = self.descriptionLabel.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(text: .plain(NSAttributedString(string: strings.Story_Guide_Description, font: Font.regular(15.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.6), paragraphAlignment: .center)), maximumNumberOfLines: 0, lineSpacing: 0.2)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
let buttonSize = self.proceedButton.update(
transition: .immediate,
component: AnyComponent(Button(
content: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Story_Guide_Proceed, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center)))),
action: { [weak self] in
self?.handleTap()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
let totalHeight = titleSize.height + textSpacing + textSize.height + itemsSpacing + itemsSize.height + buttonSpacing + buttonSize.height
let originY = floorToScreenPixels((availableSize.height - totalHeight) / 2.0)
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: originY), size: titleSize)
if let view = self.titleLabel.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = titleFrame
}
let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - textSize.width) / 2.0), y: titleFrame.maxY + 7.0), size: textSize)
if let view = self.descriptionLabel.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = textFrame
}
let itemsFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - itemsSize.width) / 2.0), y: textFrame.maxY + 40.0), size: itemsSize)
if let view = self.guideItems.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = itemsFrame
}
let buttonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - buttonSize.width) / 2.0), y: itemsFrame.maxY + 57.0), size: buttonSize)
if let view = self.proceedButton.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = buttonFrame
}
let bounds = CGRect(origin: .zero, size: availableSize)
self.effectView.frame = bounds
self.containerView.frame = bounds
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class GuideItemComponent: Component {
let context: AccountContext
let title: String
let text: String
let animationName: String
let isPlaying: Bool
let playbackCompleted: () -> Void
init(
context: AccountContext,
title: String,
text: String,
animationName: String,
isPlaying: Bool,
playbackCompleted: @escaping () -> Void
) {
self.context = context
self.title = title
self.text = text
self.animationName = animationName
self.isPlaying = isPlaying
self.playbackCompleted = playbackCompleted
}
static func ==(lhs: GuideItemComponent, rhs: GuideItemComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.animationName != rhs.animationName {
return false
}
if lhs.isPlaying != rhs.isPlaying {
return false
}
return true
}
final class View: UIView {
private var component: GuideItemComponent?
private weak var state: EmptyComponentState?
private let containerView = UIView()
private let selectionView = UIView()
private let animation = ComponentView<Empty>()
private let titleLabel = ComponentView<Empty>()
private let descriptionLabel = ComponentView<Empty>()
override init(frame: CGRect) {
super.init(frame: frame)
self.selectionView.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.1)
self.selectionView.clipsToBounds = true
self.selectionView.layer.cornerRadius = 16.0
if #available(iOS 13.0, *) {
self.selectionView.layer.cornerCurve = .continuous
}
self.selectionView.alpha = 0.0
self.addSubview(self.containerView)
self.containerView.addSubview(self.selectionView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var isPlaying = false
func update(component: GuideItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let originX = availableSize.width / 2.0 - 120.0
let animationSize = self.animation.update(
transition: .immediate,
component: AnyComponent(
LottieComponent(
content: LottieComponent.AppBundleContent(name: component.animationName),
color: .white,
placeholderColor: nil,
startingPosition: .begin,
size: CGSize(width: 60.0, height: 60.0),
renderingScale: UIScreen.main.scale,
loop: false
)
),
environment: {},
containerSize: availableSize
)
let animationFrame = CGRect(origin: CGPoint(x: originX - 11.0, y: 15.0), size: animationSize)
if let view = self.animation.view as? LottieComponent.View {
if view.superview == nil {
view.externalShouldPlay = false
self.containerView.addSubview(view)
}
view.frame = animationFrame
if component.isPlaying && !self.isPlaying {
self.isPlaying = true
Queue.mainQueue().justDispatch {
let completionBlock = { [weak self] in
guard let self else {
return
}
self.isPlaying = false
Queue.mainQueue().after(0.1) {
self.component?.playbackCompleted()
}
}
view.playOnce(force: true, completion: { [weak view] in
view?.playOnce(force: true, completion: {
completionBlock()
})
})
}
}
}
let availableTextWidth = availableSize.width - originX - 60.0 - 18.0
let titleSize = self.titleLabel.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .natural)),
maximumNumberOfLines: 2
)
),
environment: {},
containerSize: CGSize(width: availableTextWidth, height: availableSize.height)
)
let titleFrame = CGRect(origin: CGPoint(x: originX + 60.0, y: 25.0), size: titleSize)
if let view = self.titleLabel.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = titleFrame
}
let textSize = self.descriptionLabel.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: component.text, font: Font.regular(15.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.6), paragraphAlignment: .natural)),
maximumNumberOfLines: 3
)
),
environment: {},
containerSize: CGSize(width: availableTextWidth, height: availableSize.height)
)
let textFrame = CGRect(origin: CGPoint(x: originX + 60.0, y: titleFrame.maxY + 2.0), size: textSize)
if let view = self.descriptionLabel.view {
if view.superview == nil {
self.containerView.addSubview(view)
}
view.frame = textFrame
}
let size = CGSize(width: availableSize.width, height: 53.0 + titleSize.height + textSize.height)
self.selectionView.frame = CGRect(origin: .zero, size: size).insetBy(dx: 10.0, dy: 8.0)
transition.setAlpha(view: self.selectionView, alpha: component.isPlaying ? 1.0 : 0.0)
self.containerView.bounds = CGRect(origin: .zero, size: size)
self.containerView.center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
transition.setScale(view: self.containerView, scale: component.isPlaying ? 1.1 : 1.0)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,382 @@
import Foundation
import UIKit
import AccountContext
import TelegramCore
import Postbox
import SwiftSignalKit
import ComponentFlow
import TinyThumbnail
import ImageBlur
import MediaResources
import Display
import TelegramPresentationData
import BundleIconComponent
import MultilineTextComponent
final class StoryItemImageView: UIView {
private let contentView: UIImageView
private var captureProtectedView: UITextField?
private var captureProtectedInfo: ComponentView<Empty>?
private var currentMedia: EngineMedia?
private var disposable: Disposable?
private var fetchDisposable: Disposable?
private(set) var isContentLoaded: Bool = false
var didLoadContents: (() -> Void)?
override init(frame: CGRect) {
self.contentView = UIImageView()
self.contentView.contentMode = .scaleAspectFill
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable?.dispose()
}
private func updateImage(image: UIImage, isCaptureProtected: Bool) {
self.contentView.image = image
if isCaptureProtected {
let captureProtectedView: UITextField
if let current = self.captureProtectedView {
captureProtectedView = current
} else {
captureProtectedView = UITextField(frame: self.contentView.frame)
captureProtectedView.isSecureTextEntry = true
self.captureProtectedView = captureProtectedView
self.layer.addSublayer(captureProtectedView.layer)
captureProtectedView.layer.sublayers?.first?.addSublayer(self.contentView.layer)
}
} else {
if self.contentView.layer.superlayer !== self.layer {
self.layer.addSublayer(self.contentView.layer)
}
if let captureProtectedView = self.captureProtectedView {
self.captureProtectedView = nil
captureProtectedView.layer.removeFromSuperlayer()
}
}
}
func update(context: AccountContext, strings: PresentationStrings, peer: EnginePeer, storyId: Int32, media: EngineMedia, size: CGSize, isCaptureProtected: Bool, attemptSynchronous: Bool, transition: ComponentTransition) {
self.backgroundColor = isCaptureProtected ? UIColor(rgb: 0x181818) : nil
var dimensions: CGSize?
let isMediaUpdated: Bool
if let currentMedia = self.currentMedia {
isMediaUpdated = !currentMedia._asMedia().isSemanticallyEqual(to: media._asMedia())
} else {
isMediaUpdated = true
}
switch media {
case let .image(image):
if let representation = largestImageRepresentation(image.representations) {
dimensions = representation.dimensions.cgSize
if isMediaUpdated {
if attemptSynchronous, let path = context.account.postbox.mediaBox.completedResourcePath(id: representation.resource.id, pathExtension: nil) {
if #available(iOS 15.0, *) {
if let image = UIImage(contentsOfFile: path)?.preparingForDisplay() {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
} else {
if let image = UIImage(contentsOfFile: path)?.precomposed() {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
}
self.isContentLoaded = true
self.didLoadContents?()
} else {
if let thumbnailData = image.immediateThumbnailData.flatMap(decodeTinyThumbnail), let thumbnailImage = UIImage(data: thumbnailData) {
if let image = blurredImage(thumbnailImage, radius: 10.0, iterations: 3) {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
}
if let peerReference = PeerReference(peer._asPeer()) {
self.fetchDisposable = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .story, reference: .media(media: .story(peer: peerReference, id: storyId, media: media._asMedia()), resource: representation.resource), ranges: nil).start()
}
self.disposable = (context.account.postbox.mediaBox.resourceData(representation.resource, option: .complete(waitUntilFetchStatus: false))
|> map { result -> UIImage? in
if result.complete {
if #available(iOS 15.0, *) {
if let image = UIImage(contentsOfFile: result.path)?.preparingForDisplay() {
return image
} else {
return nil
}
} else {
if let image = UIImage(contentsOfFile: result.path)?.precomposed() {
return image
} else {
return nil
}
}
} else {
return nil
}
}
|> deliverOnMainQueue).start(next: { [weak self] image in
guard let self else {
return
}
if let image {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
self.isContentLoaded = true
self.didLoadContents?()
}
})
}
}
}
case let .file(file):
dimensions = file.dimensions?.cgSize
if isMediaUpdated {
let cachedPath = context.account.postbox.mediaBox.cachedRepresentationCompletePath(file.resource.id, representation: CachedVideoFirstFrameRepresentation())
if attemptSynchronous, FileManager.default.fileExists(atPath: cachedPath) {
if #available(iOS 15.0, *) {
if let image = UIImage(contentsOfFile: cachedPath)?.preparingForDisplay() {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
} else {
if let image = UIImage(contentsOfFile: cachedPath)?.precomposed() {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
}
self.isContentLoaded = true
self.didLoadContents?()
} else {
if let thumbnailData = file.immediateThumbnailData.flatMap(decodeTinyThumbnail), let thumbnailImage = UIImage(data: thumbnailData) {
if let image = blurredImage(thumbnailImage, radius: 10.0, iterations: 3) {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
}
let fullSize = context.account.postbox.mediaBox.cachedResourceRepresentation(file.resource, representation: CachedVideoFirstFrameRepresentation(), complete: true, fetch: true, attemptSynchronously: false)
var previewSize: Signal<MediaResourceData?, NoError> = .single(nil)
if let representation = file.previewRepresentations.first {
previewSize = context.account.postbox.mediaBox.resourceData(representation.resource, option: .complete(waitUntilFetchStatus: false))
|> map(Optional.init)
}
self.disposable = (combineLatest(
fullSize,
previewSize
)
|> map { result, previewResult -> UIImage? in
if result.complete {
if #available(iOS 15.0, *) {
if let image = UIImage(contentsOfFile: result.path)?.preparingForDisplay() {
return image
} else {
return nil
}
} else {
if let image = UIImage(contentsOfFile: result.path)?.precomposed() {
return image
} else {
return nil
}
}
} else if let previewResult, previewResult.complete {
if #available(iOS 15.0, *) {
if let image = UIImage(contentsOfFile: previewResult.path)?.preparingForDisplay() {
return image
} else {
return nil
}
} else {
if let image = UIImage(contentsOfFile: previewResult.path)?.precomposed() {
return image
} else {
return nil
}
}
} else {
return nil
}
}
|> deliverOnMainQueue).start(next: { [weak self] image in
guard let self else {
return
}
if let image {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
self.isContentLoaded = true
self.didLoadContents?()
}
})
}
}
case .liveStream:
dimensions = CGSize(width: 128.0, height: 128.0)
if isMediaUpdated, let thumbnailData = peer.smallProfileImage?.immediateThumbnailData {
if let thumbnailData = decodeTinyThumbnail(data: thumbnailData), let thumbnailImage = UIImage(data: thumbnailData) {
if let image = blurredImage(thumbnailImage, radius: 10.0, iterations: 3) {
self.updateImage(image: image, isCaptureProtected: isCaptureProtected)
}
}
}
default:
break
}
self.currentMedia = media
if let dimensions {
let filledSize = dimensions.aspectFilled(size)
let contentFrame = CGRect(origin: CGPoint(x: floor((size.width - filledSize.width) * 0.5), y: floor((size.height - filledSize.height) * 0.5)), size: filledSize)
if let captureProtectedView = self.captureProtectedView {
transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: contentFrame.size))
transition.setFrame(view: captureProtectedView, frame: contentFrame)
} else {
transition.setFrame(view: self.contentView, frame: contentFrame)
}
}
if isCaptureProtected {
let captureProtectedInfo: ComponentView<Empty>
var captureProtectedInfoTransition = transition
if let current = self.captureProtectedInfo {
captureProtectedInfo = current
} else {
captureProtectedInfoTransition = transition.withAnimation(.none)
captureProtectedInfo = ComponentView()
self.captureProtectedInfo = captureProtectedInfo
}
let captureProtectedInfoSize = captureProtectedInfo.update(
transition: captureProtectedInfoTransition,
component: AnyComponent(CaptureProtectedInfoComponent(
strings: strings
)),
environment: {},
containerSize: size
)
if let captureProtectedInfoView = captureProtectedInfo.view {
if captureProtectedInfoView.superview == nil {
self.insertSubview(captureProtectedInfoView, at: 0)
}
captureProtectedInfoTransition.setFrame(view: captureProtectedInfoView, frame: CGRect(origin: CGPoint(x: floor((size.width - captureProtectedInfoSize.width) * 0.5), y: floor((size.height - captureProtectedInfoSize.height) * 0.5)), size: captureProtectedInfoSize))
captureProtectedInfoView.isHidden = false
}
} else if let captureProtectedInfo = self.captureProtectedInfo {
self.captureProtectedInfo = nil
captureProtectedInfo.view?.removeFromSuperview()
}
}
}
final class CaptureProtectedInfoComponent: Component {
let strings: PresentationStrings
init(strings: PresentationStrings) {
self.strings = strings
}
static func ==(lhs: CaptureProtectedInfoComponent, rhs: CaptureProtectedInfoComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
return true
}
final class View: UIView {
let icon = ComponentView<Empty>()
let title = ComponentView<Empty>()
let text = ComponentView<Empty>()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: CaptureProtectedInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(
name: "Stories/ScreenshotsOffIcon",
tintColor: .white,
maxSize: nil
)),
environment: {},
containerSize: availableSize
)
let titleSize = self.title.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.Story_ScreenshotBlockedTitle, font: Font.semibold(20.0), textColor: .white)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: availableSize
)
let textSize = self.text.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.Story_ScreenshotBlockedText, font: Font.regular(17.0), textColor: UIColor(white: 1.0, alpha: 0.6))),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: min(320.0, availableSize.width - 16.0), height: availableSize.height)
)
var contentWidth: CGFloat = 0.0
contentWidth = max(contentWidth, iconSize.width)
contentWidth = max(contentWidth, titleSize.width)
contentWidth = max(contentWidth, textSize.width)
var contentHeight: CGFloat = 0.0
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: floor((contentWidth - iconSize.width) * 0.5), y: contentHeight), size: iconSize))
}
contentHeight += iconSize.height + 11.0
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((contentWidth - titleSize.width) * 0.5), y: contentHeight), size: titleSize))
}
contentHeight += titleSize.height + 16.0
if let textView = self.text.view {
if textView.superview == nil {
self.addSubview(textView)
}
transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: floor((contentWidth - textSize.width) * 0.5), y: contentHeight), size: textSize))
}
contentHeight += textSize.height
return CGSize(width: contentWidth, height: contentHeight)
}
}
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,127 @@
import Foundation
import UIKit
import HierarchyTrackingLayer
import ComponentFlow
import Display
private let shadowImage: UIImage? = {
UIImage(named: "Stories/PanelGradient")
}()
final class StoryItemLoadingEffectView: UIView {
private let duration: Double
private let hasCustomBorder: Bool
private let playOnce: Bool
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private let gradientWidth: CGFloat
private let backgroundView: UIImageView
private let borderGradientView: UIImageView
private let borderContainerView: UIView
let borderMaskLayer: SimpleShapeLayer
private var didPlayOnce = false
init(effectAlpha: CGFloat, borderAlpha: CGFloat, gradientWidth: CGFloat = 200.0, duration: Double, hasCustomBorder: Bool, playOnce: Bool) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
self.duration = duration
self.hasCustomBorder = hasCustomBorder
self.playOnce = playOnce
self.gradientWidth = gradientWidth
self.backgroundView = UIImageView()
self.borderGradientView = UIImageView()
self.borderContainerView = UIView()
self.borderMaskLayer = SimpleShapeLayer()
super.init(frame: .zero)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
guard let self, self.bounds.width != 0.0 else {
return
}
self.updateAnimations(size: self.bounds.size)
}
let generateGradient: (CGFloat) -> UIImage? = { baseAlpha in
return generateImage(CGSize(width: self.gradientWidth, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0))
if let shadowImage {
UIGraphicsPushContext(context)
for i in 0 ..< 2 {
let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height))
context.saveGState()
context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY)
context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5)
let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width))
context.clip(to: adjustedRect, mask: shadowImage.cgImage!)
context.setFillColor(foregroundColor.cgColor)
context.fill(adjustedRect)
context.restoreGState()
}
UIGraphicsPopContext()
}
})
}
self.backgroundView.image = generateGradient(effectAlpha)
self.addSubview(self.backgroundView)
self.borderGradientView.image = generateGradient(borderAlpha)
self.borderContainerView.addSubview(self.borderGradientView)
self.addSubview(self.borderContainerView)
self.borderContainerView.layer.mask = self.borderMaskLayer
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateAnimations(size: CGSize) {
if self.backgroundView.layer.animation(forKey: "shimmer") != nil || (self.playOnce && self.didPlayOnce) {
return
}
let animation = self.backgroundView.layer.makeAnimation(from: 0.0 as NSNumber, to: (size.width + self.gradientWidth + size.width * 0.2) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = self.playOnce ? 1 : Float.infinity
self.backgroundView.layer.add(animation, forKey: "shimmer")
self.borderGradientView.layer.add(animation, forKey: "shimmer")
self.didPlayOnce = true
}
func update(size: CGSize, transition: ComponentTransition) {
let backgroundSize = CGSize(width: self.gradientWidth, height: size.height)
if self.backgroundView.bounds.size != backgroundSize {
self.backgroundView.layer.removeAllAnimations()
if !self.hasCustomBorder {
self.borderMaskLayer.fillColor = nil
self.borderMaskLayer.strokeColor = UIColor.white.cgColor
let lineWidth: CGFloat = 3.0
self.borderMaskLayer.lineWidth = lineWidth
self.borderMaskLayer.path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: 12.0).cgPath
}
}
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: backgroundSize))
transition.setFrame(view: self.borderContainerView, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.borderGradientView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height)))
self.updateAnimations(size: size)
}
}
@@ -0,0 +1,812 @@
import Foundation
import UIKit
import AccountContext
import TelegramCore
import Postbox
import SwiftSignalKit
import ComponentFlow
import TinyThumbnail
import ImageBlur
import MediaResources
import Display
import TelegramPresentationData
import BundleIconComponent
import MultilineTextComponent
import AppBundle
import EmojiTextAttachmentView
import TextFormat
import AnimatedCountLabelNode
import LottieComponent
import LottieComponentResourceContent
import StickerResources
import AnimationCache
import TelegramStringFormatting
private let shadowImage: UIImage = {
return UIImage(bundleImageName: "Stories/ReactionShadow")!
}()
private let coverImage: UIImage = {
return UIImage(bundleImageName: "Stories/ReactionOutline")!
}()
private let darkCoverImage: UIImage = {
return generateTintedImage(image: UIImage(bundleImageName: "Stories/ReactionOutline"), color: UIColor(rgb: 0x000000, alpha: 0.5))!
}()
public func storyPreviewWithAddedReactions(
context: AccountContext,
storyItem: Stories.Item,
signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>
) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> {
var reactionData: [Signal<(MessageReaction.Reaction, CGImage?), NoError>] = []
let loadFile: (MessageReaction.Reaction, TelegramMediaFile) -> Signal<(MessageReaction.Reaction, CGImage?), NoError> = { reaction, file in
return Signal { subscriber in
let isTemplate = !"".isEmpty
return context.animationRenderer.loadFirstFrameAsImage(cache: context.animationCache, itemId: file.resource.id.stringRepresentation, size: CGSize(width: 128.0, height: 128.0), fetch: animationCacheFetchFile(postbox: context.account.postbox, userLocation: .other, userContentType: .sticker, resource: .media(media: .standalone(media: file), resource: file.resource), type: AnimationCacheAnimationType(file: file), keyframeOnly: true, customColor: isTemplate ? .white : nil), completion: { result in
subscriber.putNext((reaction, result))
if result != nil {
subscriber.putCompletion()
}
})
}
|> distinctUntilChanged(isEqual: { lhs, rhs in
if lhs.0 != rhs.0 {
return false
}
if lhs.1 !== rhs.1 {
return false
}
return true
})
}
var availableReactions: Promise<AvailableReactions?>?
var processedReactions: [MessageReaction.Reaction] = []
var customFileIds: [Int64] = []
for mediaArea in storyItem.mediaAreas {
if case let .reaction(_, reaction, _) = mediaArea {
if processedReactions.contains(reaction) {
continue
}
processedReactions.append(reaction)
switch reaction {
case .builtin:
if availableReactions == nil {
availableReactions = Promise()
availableReactions?.set(context.engine.stickers.availableReactions())
}
reactionData.append(availableReactions!.get()
|> take(1)
|> mapToSignal { availableReactions -> Signal<(MessageReaction.Reaction, CGImage?), NoError> in
guard let availableReactions else {
return .single((reaction, nil))
}
for item in availableReactions.reactions {
if item.value == reaction {
guard let file = item.centerAnimation else {
break
}
return loadFile(reaction, file._parse())
}
}
return .single((reaction, nil))
})
case let .custom(fileId):
if !customFileIds.contains(fileId) {
customFileIds.append(fileId)
}
case .stars:
break
}
}
}
if !customFileIds.isEmpty {
let customFiles = Promise<[Int64: TelegramMediaFile]>()
customFiles.set(context.engine.stickers.resolveInlineStickers(fileIds: customFileIds))
for id in customFileIds {
reactionData.append(customFiles.get()
|> take(1)
|> mapToSignal { customFiles -> Signal<(MessageReaction.Reaction, CGImage?), NoError> in
let reaction: MessageReaction.Reaction = .custom(id)
guard let file = customFiles[id] else {
return .single((reaction, nil))
}
return loadFile(reaction, file)
})
}
}
return combineLatest(
signal,
combineLatest(reactionData)
)
|> map { draw, reactionsData in
return { arguments in
guard let context = draw(arguments) else {
return nil
}
let drawingRect = arguments.drawingRect
var fittedSize = arguments.imageSize
if abs(fittedSize.width - arguments.boundingSize.width).isLessThanOrEqualTo(CGFloat(1.0)) {
fittedSize.width = arguments.boundingSize.width
}
if abs(fittedSize.height - arguments.boundingSize.height).isLessThanOrEqualTo(CGFloat(1.0)) {
fittedSize.height = arguments.boundingSize.height
}
let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize)
context.withContext { c in
c.concatenate(c.ctm.inverted())
c.scaleBy(x: context.scale, y: context.scale)
}
context.withFlippedContext { c in
c.setBlendMode(.normal)
for mediaArea in storyItem.mediaAreas {
c.saveGState()
defer {
c.restoreGState()
}
if case let .reaction(coordinates, reaction, flags) = mediaArea {
let _ = reaction
let _ = flags
let referenceSize = fittedRect.size
var areaSize = CGSize(width: coordinates.width / 100.0 * referenceSize.width, height: coordinates.height / 100.0 * referenceSize.height)
areaSize.width *= 0.97
areaSize.height *= 0.97
let targetFrame = CGRect(x: coordinates.x / 100.0 * referenceSize.width - areaSize.width * 0.5, y: coordinates.y / 100.0 * referenceSize.height - areaSize.height * 0.5, width: areaSize.width, height: areaSize.height)
if targetFrame.width < 2.0 || targetFrame.height < 2.0 {
continue
}
c.saveGState()
c.translateBy(x: targetFrame.midX, y: targetFrame.midY)
c.scaleBy(x: flags.contains(.isFlipped) ? -1.0 : 1.0, y: -1.0)
c.rotate(by: -coordinates.rotation * (CGFloat.pi / 180.0))
c.translateBy(x: -targetFrame.midX, y: -targetFrame.midY)
let insets = UIEdgeInsets(top: -0.08, left: -0.05, bottom: -0.01, right: -0.02)
let coverFrame = CGRect(origin: CGPoint(x: targetFrame.width * insets.left, y: targetFrame.height * insets.top), size: CGSize(width: targetFrame.width - targetFrame.width * insets.left - targetFrame.width * insets.right, height: targetFrame.height - targetFrame.height * insets.top - targetFrame.height * insets.bottom)).offsetBy(dx: targetFrame.minX, dy: targetFrame.minY)
c.draw(shadowImage.cgImage!, in: coverFrame)
if flags.contains(.isDark) {
c.draw(darkCoverImage.cgImage!, in: coverFrame)
} else {
c.draw(coverImage.cgImage!, in: coverFrame)
}
c.restoreGState()
c.translateBy(x: targetFrame.midX, y: targetFrame.midY)
c.scaleBy(x: 1.0, y: -1.0)
c.rotate(by: -coordinates.rotation * (CGFloat.pi / 180.0))
c.translateBy(x: -targetFrame.midX, y: -targetFrame.midY)
let minSide = floor(min(200.0, min(targetFrame.width, targetFrame.height)) * 0.5)
let itemSize = CGSize(width: minSide, height: minSide)
if let (_, maybeImage) = reactionsData.first(where: { $0.0 == reaction }), let image = maybeImage {
var imageFrame = itemSize.centered(around: targetFrame.center.offsetBy(dx: -targetFrame.height * 0.015, dy: -targetFrame.height * 0.05))
if case .builtin = reaction {
imageFrame = imageFrame.insetBy(dx: -imageFrame.width * 0.5, dy: -imageFrame.height * 0.5)
}
c.draw(image, in: imageFrame)
}
}
}
}
context.withContext { c in
c.concatenate(c.ctm.inverted())
c.scaleBy(x: context.scale, y: context.scale)
c.scaleBy(x: context.size.width * 0.5, y: context.size.height * 0.5)
c.scaleBy(x: 1.0, y: -1.0)
c.scaleBy(x: -context.size.width * 0.5, y: -context.size.height * 0.5)
}
addCorners(context, arguments: arguments)
return context
}
}
}
private protocol ItemView: UIView {
}
final class StoryItemOverlaysView: UIView {
static let counterFont: UIFont = {
return Font.with(size: 17.0, design: .camera, weight: .semibold, traits: .monospacedNumbers)
}()
private final class ReactionView: HighlightTrackingButton, ItemView {
private let shadowView: UIImageView
private let coverView: UIImageView
private var directStickerView: ComponentView<Empty>?
private var customEmojiView: EmojiTextAttachmentView?
private var file: TelegramMediaFile?
private var counterText: AnimatedCountLabelView?
private var reaction: MessageReaction.Reaction?
var activate: ((UIView, MessageReaction.Reaction) -> Void)?
var requestUpdate: (() -> Void)?
private var requestStickerDisposable: Disposable?
private var resolvedFile: TelegramMediaFile?
private var customEmojiLoadDisposable: Disposable?
override init(frame: CGRect) {
self.shadowView = UIImageView(image: shadowImage)
self.coverView = UIImageView(image: coverImage)
super.init(frame: frame)
self.addSubview(self.shadowView)
self.addSubview(self.coverView)
self.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
let transition = ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut))
transition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(0.9, 0.9, 1.0))
} else {
let transition: ComponentTransition = .immediate
transition.setSublayerTransform(view: self, transform: CATransform3DIdentity)
var fromScale: Double = 0.9
if self.layer.animation(forKey: "sublayerTransform") != nil, let presentation = self.layer.presentation() {
let t = presentation.sublayerTransform
fromScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
}
self.layer.animateSpring(from: fromScale as NSNumber, to: 1.0 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.4)
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.requestStickerDisposable?.dispose()
self.customEmojiLoadDisposable?.dispose()
}
@objc private func pressed() {
guard let activate = self.activate, let reaction = self.reaction else {
return
}
activate(self, reaction)
}
func update(
context: AccountContext,
reaction: MessageReaction.Reaction,
flags: MediaArea.ReactionFlags,
counter: Int,
availableReactions: StoryAvailableReactions?,
entityFiles: [MediaId: TelegramMediaFile],
synchronous: Bool,
size: CGSize,
isActive: Bool
) {
var transition = ComponentTransition(animation: .curve(duration: 0.18, curve: .easeInOut))
if self.reaction == nil {
transition = .immediate
}
self.reaction = reaction
let insets = UIEdgeInsets(top: -0.08, left: -0.05, bottom: -0.01, right: -0.02)
self.coverView.frame = CGRect(origin: CGPoint(x: size.width * insets.left, y: size.height * insets.top), size: CGSize(width: size.width - size.width * insets.left - size.width * insets.right, height: size.height - size.height * insets.top - size.height * insets.bottom))
self.shadowView.frame = self.coverView.frame
if flags.contains(.isFlipped) {
self.coverView.transform = CGAffineTransformMakeScale(-1.0, 1.0)
self.shadowView.transform = self.coverView.transform
}
self.coverView.tintColor = flags.contains(.isDark) ? UIColor(rgb: 0x000000, alpha: 0.5) : UIColor.white
let minSide = floor(min(200.0, min(size.width, size.height)) * 0.65)
let itemSize = CGSize(width: minSide, height: minSide)
var file: TelegramMediaFile? = self.file
if self.file == nil {
switch reaction {
case .builtin:
if let availableReactions {
for reactionItem in availableReactions.reactionItems {
if reactionItem.reaction.rawValue == reaction {
file = reactionItem.stillAnimation._parse()
break
}
}
}
case let .custom(fileId):
if let resolvedFile = self.resolvedFile, resolvedFile.fileId.id == fileId {
file = resolvedFile
} else if let value = entityFiles[MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)] {
file = value
} else {
if self.requestStickerDisposable == nil {
self.requestStickerDisposable = (context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
if let value = result[fileId] {
self.resolvedFile = value
self.requestUpdate?()
}
})
}
}
case .stars:
if let availableReactions {
for reactionItem in availableReactions.reactionItems {
if reactionItem.reaction.rawValue == reaction {
file = reactionItem.stillAnimation._parse()
break
}
}
}
}
}
if counter != 0 {
var conterTransition = transition
let counterText: AnimatedCountLabelView
if let current = self.counterText {
counterText = current
} else {
conterTransition = conterTransition.withAnimation(.none)
counterText = AnimatedCountLabelView(frame: CGRect())
counterText.isUserInteractionEnabled = false
self.counterText = counterText
self.addSubview(counterText)
}
var segments: [AnimatedCountLabelView.Segment] = []
segments.append(.number(counter, NSAttributedString(string: "\(counter)", font: counterFont, textColor: flags.contains(.isDark) ? .white : .black)))
let counterTextLayout = counterText.update(size: CGSize(width: 200.0, height: 200.0), segments: segments, transition: conterTransition.containedViewLayoutTransition)
conterTransition.setPosition(view: counterText, position: CGPoint(x: size.width * 0.5, y: size.height * 0.765))
conterTransition.setBounds(view: counterText, bounds: CGRect(origin: CGPoint(), size: counterTextLayout.size))
let counterScale = max(0.01, min(1.8, size.width / 140.0))
conterTransition.setScale(view: counterText, scale: counterScale)
if !transition.animation.isImmediate && conterTransition.animation.isImmediate {
transition.animateAlpha(view: counterText, from: 0.0, to: 1.0)
transition.animateScale(view: counterText, from: 0.001, to: counterScale)
}
} else {
if let counterText = self.counterText {
self.counterText = nil
transition.setAlpha(view: counterText, alpha: 0.0, completion: { [weak counterText] _ in
counterText?.removeFromSuperview()
})
transition.setScale(view: counterText, scale: 0.001)
}
}
if self.file?.fileId != file?.fileId, let file {
self.file = file
let isBuiltinSticker = file.isAnimatedSticker && !file.isVideoSticker && !file.isVideoEmoji
if isBuiltinSticker {
let directStickerView: ComponentView<Empty>
if let current = self.directStickerView {
directStickerView = current
} else {
directStickerView = ComponentView()
self.directStickerView = directStickerView
self.customEmojiLoadDisposable?.dispose()
self.customEmojiLoadDisposable = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .other, userContentType: .sticker, reference: .standalone(resource: file.resource)).start()
}
var color: UIColor?
if file.isCustomTemplateEmoji {
color = flags.contains(.isDark) ? .white : .black
}
let placeholderColor = flags.contains(.isDark) ? UIColor(white: 1.0, alpha: 0.1) : UIColor(white: 0.0, alpha: 0.1)
let _ = directStickerView.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.ResourceContent(context: context, file: file, attemptSynchronously: synchronous, providesPlaceholder: true),
color: color,
placeholderColor: placeholderColor,
renderingScale: 2.0,
loop: true
)),
environment: {},
containerSize: itemSize
)
} else {
if let directStickerView = self.directStickerView {
self.directStickerView = nil
directStickerView.view?.removeFromSuperview()
}
}
if !isBuiltinSticker {
let customEmojiView: EmojiTextAttachmentView
if let current = self.customEmojiView {
customEmojiView = current
} else {
customEmojiView = EmojiTextAttachmentView(
context: context,
userLocation: .other,
emoji: ChatTextInputTextCustomEmojiAttribute(
interactivelySelectedFromPackId: nil,
fileId: file.fileId.id,
file: file
),
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
placeholderColor: flags.contains(.isDark) ? UIColor(white: 1.0, alpha: 0.1) : UIColor(white: 0.0, alpha: 0.1),
pointSize: CGSize(width: min(256, itemSize.width), height: min(256, itemSize.height))
)
customEmojiView.clipsToBounds = true
customEmojiView.updateTextColor(flags.contains(.isDark) ? .white : .black)
self.customEmojiLoadDisposable?.dispose()
self.customEmojiLoadDisposable = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .other, userContentType: .sticker, reference: .standalone(resource: file.resource)).start()
customEmojiView.isUserInteractionEnabled = false
self.customEmojiView = customEmojiView
self.addSubview(customEmojiView)
}
} else {
if let customEmojiView = self.customEmojiView {
self.customEmojiView = nil
customEmojiView.removeFromSuperview()
}
}
}
if let customEmojiView = self.customEmojiView {
var stickerTransition = transition
if customEmojiView.bounds.isEmpty {
stickerTransition = stickerTransition.withAnimation(.none)
}
let counterFractionOffset: CGFloat
let stickerScale: CGFloat
if counter != 0 {
counterFractionOffset = -0.05
stickerScale = 0.8
} else {
counterFractionOffset = 0.0
stickerScale = 1.0
}
let stickerFrame = itemSize.centered(around: CGPoint(x: size.width * 0.49, y: size.height * (0.47 + counterFractionOffset)))
stickerTransition.setPosition(view: customEmojiView, position: stickerFrame.center)
stickerTransition.setBounds(view: customEmojiView, bounds: CGRect(origin: CGPoint(), size: stickerFrame.size))
stickerTransition.setScale(view: customEmojiView, scale: stickerScale)
customEmojiView.layer.cornerRadius = stickerFrame.size.width * 0.1
customEmojiView.isActive = isActive
}
if let directStickerView = self.directStickerView?.view as? LottieComponent.View {
var stickerTransition = transition
if directStickerView.superview == nil {
self.addSubview(directStickerView)
stickerTransition = stickerTransition.withAnimation(.none)
}
let counterFractionOffset: CGFloat
let stickerScale: CGFloat
if counter != 0 {
counterFractionOffset = -0.05
stickerScale = 0.8
} else {
counterFractionOffset = 0.0
stickerScale = 1.0
}
let stickerFrame = itemSize.centered(around: CGPoint(x: size.width * 0.49, y: size.height * (0.47 + counterFractionOffset)))
stickerTransition.setPosition(view: directStickerView, position: stickerFrame.center)
stickerTransition.setBounds(view: directStickerView, bounds: CGRect(origin: CGPoint(), size: stickerFrame.size))
stickerTransition.setScale(view: directStickerView, scale: stickerScale)
directStickerView.externalShouldPlay = isActive
}
}
}
private final class WeatherView: UIView, ItemView {
private let backgroundView = UIView()
private let directStickerView = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private var file: TelegramMediaFile?
private var textFont: UIFont?
private var customEmojiLoadDisposable: Disposable?
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundView.clipsToBounds = true
if #available(iOS 13.0, *) {
self.backgroundView.layer.cornerCurve = .continuous
}
self.addSubview(self.backgroundView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.customEmojiLoadDisposable?.dispose()
}
func update(
context: AccountContext,
emoji: String,
emojiFile: TelegramMediaFile?,
temperature: Double,
color: Int32,
synchronous: Bool,
size: CGSize,
cornerRadius: CGFloat,
isActive: Bool
) -> CGSize {
let itemSize = CGSize(width: floor(size.height * 0.71), height: floor(size.height * 0.71))
let backgroundColor = UIColor(argb: UInt32(bitPattern: color))
let textColor: UIColor
if backgroundColor.lightness > 0.705 {
textColor = .black
} else {
textColor = .white
}
let placeholderColor = textColor.withAlphaComponent(0.1)
if self.file?.fileId != emojiFile?.fileId, let file = emojiFile {
self.file = file
self.customEmojiLoadDisposable?.dispose()
self.customEmojiLoadDisposable = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .other, userContentType: .sticker, reference: .standalone(resource: file.resource)).start()
let _ = self.directStickerView.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.ResourceContent(context: context, file: file, attemptSynchronously: synchronous, providesPlaceholder: true),
placeholderColor: placeholderColor,
renderingScale: 2.0,
loop: true
)),
environment: {},
containerSize: itemSize
)
}
let textFont: UIFont
if let current = self.textFont {
textFont = current
} else {
textFont = Font.with(size: floorToScreenPixels(size.height * 0.69), design: .camera, weight: .semibold, traits: .monospacedNumbers)
self.textFont = textFont
}
let string = NSMutableAttributedString(
string: stringForTemperature(temperature),
font: textFont,
textColor: textColor
)
string.addAttribute(.kern, value: -(size.height / 38.0) as NSNumber, range: NSMakeRange(0, string.length))
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(text: .plain(string))
),
environment: {},
containerSize: CGSize(width: .greatestFiniteMagnitude, height: size.height)
)
let leftInset = size.height * 0.058
let rightInset = size.height * 0.2
let spacing = size.height * 0.205
let contentWidth: CGFloat = leftInset + itemSize.width + spacing + textSize.width + rightInset
if let view = self.text.view {
if view.superview == nil {
self.addSubview(view)
}
let textFrame = CGRect(origin: CGPoint(x: contentWidth - textSize.width - rightInset, y: floorToScreenPixels((size.height - textSize.height) / 2.0)), size: textSize)
let textTransition = ComponentTransition.immediate
textTransition.setFrame(view: view, frame: textFrame)
}
if let directStickerView = self.directStickerView.view as? LottieComponent.View {
if directStickerView.superview == nil {
self.addSubview(directStickerView)
}
let stickerFrame = itemSize.centered(around: CGPoint(x: size.height * 0.5 + leftInset, y: size.height * 0.5))
let stickerTransition = ComponentTransition.immediate
stickerTransition.setPosition(view: directStickerView, position: stickerFrame.center)
stickerTransition.setBounds(view: directStickerView, bounds: CGRect(origin: CGPoint(), size: stickerFrame.size))
directStickerView.externalShouldPlay = isActive
}
let contentSize = CGSize(width: contentWidth, height: size.height)
self.backgroundView.backgroundColor = backgroundColor
self.backgroundView.frame = CGRect(origin: .zero, size: contentSize)
self.backgroundView.layer.cornerRadius = cornerRadius
return contentSize
}
}
private var itemViews: [Int: ItemView] = [:]
var activate: ((UIView, MessageReaction.Reaction) -> Void)?
var requestUpdate: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for (_, itemView) in self.itemViews {
if let result = itemView.hitTest(self.convert(point, to: itemView), with: event) {
return result
}
}
return nil
}
func update(
context: AccountContext,
strings: PresentationStrings,
peer: EnginePeer,
story: EngineStoryItem,
availableReactions: StoryAvailableReactions?,
entityFiles: [MediaId: TelegramMediaFile],
size: CGSize,
isCaptureProtected: Bool,
attemptSynchronous: Bool,
isActive: Bool,
transition: ComponentTransition
) {
func getFrameAndRotation(coordinates: MediaArea.Coordinates, scale: CGFloat = 1.0) -> (frame: CGRect, rotation: CGFloat, cornerRadius: CGFloat)? {
let referenceSize = size
var areaSize = CGSize(width: coordinates.width / 100.0 * referenceSize.width, height: coordinates.height / 100.0 * referenceSize.height)
areaSize.width *= scale
areaSize.height *= scale
let targetFrame = CGRect(x: coordinates.x / 100.0 * referenceSize.width - areaSize.width * 0.5, y: coordinates.y / 100.0 * referenceSize.height - areaSize.height * 0.5, width: areaSize.width, height: areaSize.height)
if targetFrame.width < 5.0 || targetFrame.height < 5.0 {
return nil
}
var cornerRadius: CGFloat = 0.0
if let radius = coordinates.cornerRadius {
cornerRadius = radius / 100.0 * areaSize.width
}
return (targetFrame, coordinates.rotation * (CGFloat.pi / 180.0), cornerRadius)
}
var nextId = 0
for mediaArea in story.mediaAreas {
switch mediaArea {
case let .reaction(coordinates, reaction, flags):
guard let (itemFrame, itemRotation, _) = getFrameAndRotation(coordinates: coordinates, scale: 0.97) else {
continue
}
let itemView: ReactionView
let itemId = nextId
if let current = self.itemViews[itemId] as? ReactionView {
itemView = current
} else {
itemView = ReactionView(frame: CGRect())
itemView.activate = { [weak self] view, reaction in
self?.activate?(view, reaction)
}
itemView.requestUpdate = { [weak self] in
self?.requestUpdate?()
}
self.itemViews[itemId] = itemView
self.addSubview(itemView)
}
transition.setPosition(view: itemView, position: itemFrame.center)
transition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
transition.setTransform(view: itemView, transform: CATransform3DMakeRotation(itemRotation, 0.0, 0.0, 1.0))
var counter = 0
if let reactionData = story.views?.reactions.first(where: { $0.value == reaction }) {
counter = Int(reactionData.count)
}
itemView.update(
context: context,
reaction: reaction,
flags: flags,
counter: counter,
availableReactions: availableReactions,
entityFiles: entityFiles,
synchronous: attemptSynchronous,
size: itemFrame.size,
isActive: isActive
)
nextId += 1
case let .weather(coordinates, emoji, temperature, color):
guard let (itemFrame, itemRotation, cornerRadius) = getFrameAndRotation(coordinates: coordinates) else {
continue
}
let itemView: WeatherView
let itemId = nextId
if let current = self.itemViews[itemId] as? WeatherView {
itemView = current
} else {
itemView = WeatherView(frame: CGRect())
itemView.isUserInteractionEnabled = false
self.itemViews[itemId] = itemView
self.addSubview(itemView)
}
let itemSize = itemView.update(
context: context,
emoji: emoji,
emojiFile: context.animatedEmojiStickersValue[emoji]?.first?.file._parse(),
temperature: temperature,
color: color,
synchronous: attemptSynchronous,
size: itemFrame.size,
cornerRadius: cornerRadius,
isActive: isActive
)
transition.setPosition(view: itemView, position: itemFrame.center)
transition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemSize))
transition.setTransform(view: itemView, transform: CATransform3DMakeRotation(itemRotation, 0.0, 0.0, 1.0))
nextId += 1
default:
break
}
}
}
}
@@ -0,0 +1,139 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AccountContext
import TelegramCore
import AsyncDisplayKit
import AvatarNode
final class StoryPrivacyIconComponent: Component {
enum Privacy {
case everyone
case closeFriends
case contacts
case selectedContacts
}
let privacy: Privacy
let isEditable: Bool
init(privacy: Privacy, isEditable: Bool) {
self.privacy = privacy
self.isEditable = isEditable
}
static func ==(lhs: StoryPrivacyIconComponent, rhs: StoryPrivacyIconComponent) -> Bool {
if lhs.privacy != rhs.privacy {
return false
}
if lhs.isEditable != rhs.isEditable {
return false
}
return true
}
final class View: UIImageView {
private let iconView = UIImageView()
private var component: StoryPrivacyIconComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.iconView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryPrivacyIconComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousPrivacy = self.component?.privacy
self.component = component
self.state = state
let colors: [CGColor]
var icon: UIImage?
if let previousPrivacy, previousPrivacy != component.privacy, !transition.animation.isImmediate {
let disappearingBackgroundView = UIImageView(image: self.image)
disappearingBackgroundView.frame = self.bounds
self.insertSubview(disappearingBackgroundView, at: 0)
disappearingBackgroundView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak disappearingBackgroundView] _ in
disappearingBackgroundView?.removeFromSuperview()
})
let disappearingIconView = UIImageView(image: self.iconView.image)
disappearingIconView.frame = self.iconView.frame
self.insertSubview(disappearingIconView, belowSubview: self.iconView)
disappearingIconView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak disappearingIconView] _ in
disappearingIconView?.removeFromSuperview()
})
disappearingIconView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false)
self.iconView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.iconView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
switch component.privacy {
case .everyone:
colors = [UIColor(rgb: 0x4faaff).cgColor, UIColor(rgb: 0x017aff).cgColor]
icon = UIImage(bundleImageName: "Stories/PrivacyEveryone")
case .closeFriends:
colors = [UIColor(rgb: 0x87d93a).cgColor, UIColor(rgb: 0x31b73b).cgColor]
icon = UIImage(bundleImageName: "Stories/PrivacyCloseFriends")
case .contacts:
colors = [UIColor(rgb: 0xc36eff).cgColor, UIColor(rgb: 0x8c61fa).cgColor]
icon = UIImage(bundleImageName: "Stories/PrivacyContacts")
case .selectedContacts:
colors = [UIColor(rgb: 0xffb643).cgColor, UIColor(rgb: 0xf69a36).cgColor]
icon = UIImage(bundleImageName: "Stories/PrivacySelectedContacts")
}
let size = CGSize(width: component.isEditable ? 40.0 : 24.0, height: 24.0)
let iconFrame = CGRect(origin: CGPoint(x: component.isEditable ? 1.0 : 0.0, y: 0.0), size: CGSize(width: size.height, height: size.height))
self.iconView.image = icon
self.iconView.bounds = CGRect(origin: .zero, size: iconFrame.size)
self.iconView.center = iconFrame.center
self.image = generateImage(size, contextGenerator: { size, context in
let bounds = CGRect(origin: .zero, size: size)
context.clear(bounds)
let path: CGPath
if size.width == size.height {
path = CGPath(ellipseIn: bounds, transform: nil)
} else {
path = CGPath(roundedRect: bounds, cornerWidth: size.height / 2.0, cornerHeight: size.height / 2.0, transform: nil)
}
context.addPath(path)
context.clip()
var locations: [CGFloat] = [1.0, 0.0]
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())
if component.isEditable {
if let arrowIcon = UIImage(bundleImageName: "Stories/PrivacyDownArrow"), let cgImage = arrowIcon.cgImage {
context.draw(cgImage, in: CGRect(origin: CGPoint(x: size.width - arrowIcon.size.width - 6.0, y: floorToScreenPixels((size.height - arrowIcon.size.height) / 2.0)), size: arrowIcon.size))
}
}
})
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,122 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
import PhotoResources
public final class StoryVideoDecoration: UniversalVideoDecoration {
public let backgroundNode: ASDisplayNode? = nil
public let contentContainerNode: ASDisplayNode
public let foregroundNode: ASDisplayNode? = nil
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private var validLayout: (size: CGSize, actualSize: CGSize)?
public init() {
self.contentContainerNode = ASDisplayNode()
}
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayout = self.validLayout {
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
}
public func updateCorners(_ corners: ImageCorners) {
self.contentContainerNode.clipsToBounds = true
if isRoundEqualCorners(corners) {
self.contentContainerNode.cornerRadius = corners.topLeft.radius
} else {
let boundingSize: CGSize = CGSize(width: max(corners.topLeft.radius, corners.bottomLeft.radius) + max(corners.topRight.radius, corners.bottomRight.radius), height: max(corners.topLeft.radius, corners.topRight.radius) + max(corners.bottomLeft.radius, corners.bottomRight.radius))
let size: CGSize = CGSize(width: boundingSize.width + corners.extendedEdges.left + corners.extendedEdges.right, height: boundingSize.height + corners.extendedEdges.top + corners.extendedEdges.bottom)
let arguments = TransformImageArguments(corners: corners, imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
guard let context = DrawingContext(size: size, clear: true) else {
return
}
context.withContext { ctx in
ctx.setFillColor(UIColor.black.cgColor)
ctx.fill(arguments.drawingRect)
}
addCorners(context, arguments: arguments)
if let maskImage = context.generateImage() {
let mask = CALayer()
mask.contents = maskImage.cgImage
mask.contentsScale = maskImage.scale
mask.contentsCenter = CGRect(x: max(corners.topLeft.radius, corners.bottomLeft.radius) / maskImage.size.width, y: max(corners.topLeft.radius, corners.topRight.radius) / maskImage.size.height, width: (maskImage.size.width - max(corners.topLeft.radius, corners.bottomLeft.radius) - max(corners.topRight.radius, corners.bottomRight.radius)) / maskImage.size.width, height: (maskImage.size.height - max(corners.topLeft.radius, corners.topRight.radius) - max(corners.bottomLeft.radius, corners.bottomRight.radius)) / maskImage.size.height)
self.contentContainerNode.layer.mask = mask
self.contentContainerNode.layer.mask?.frame = self.contentContainerNode.bounds
}
}
}
public func updateClippingFrame(_ frame: CGRect, completion: (() -> Void)?) {
self.contentContainerNode.layer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
if let maskLayer = self.contentContainerNode.layer.mask {
maskLayer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
maskLayer.animate(from: NSValue(cgPoint: maskLayer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
}
if let contentNode = self.contentNode {
contentNode.layer.animate(from: NSValue(cgPoint: contentNode.layer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
completion?()
})
}
}
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
let bounds = CGRect(origin: CGPoint(), size: size)
if let backgroundNode = self.backgroundNode {
transition.updateFrame(node: backgroundNode, frame: bounds)
}
if let foregroundNode = self.foregroundNode {
transition.updateFrame(node: foregroundNode, frame: bounds)
}
transition.updateFrame(node: self.contentContainerNode, frame: bounds)
if let maskLayer = self.contentContainerNode.layer.mask {
transition.updateFrame(layer: maskLayer, frame: bounds)
}
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
}
}
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
}
public func tap() {
}
}
@@ -0,0 +1,28 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StoryFooterPanelComponent",
module_name = "StoryFooterPanelComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/Components/BundleIconComponent",
"//submodules/AnimatedAvatarSetNode",
"//submodules/AccountContext",
"//submodules/TelegramCore",
"//submodules/TelegramUI/Components/MoreHeaderButton",
"//submodules/SemanticStatusNode",
"//submodules/AnimatedCountLabelNode",
"//submodules/TelegramUI/Components/MessageInputActionButtonComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,77 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
load(
"@build_bazel_rules_apple//apple:resources.bzl",
"apple_resource_bundle",
"apple_resource_group",
)
load("//build-system/bazel-utils:plist_fragment.bzl",
"plist_fragment",
)
filegroup(
name = "StoryPeerListMetalResources",
srcs = glob([
"MetalResources/**/*.*",
]),
visibility = ["//visibility:public"],
)
plist_fragment(
name = "StoryPeerListBundleInfoPlist",
extension = "plist",
template =
"""
<key>CFBundleIdentifier</key>
<string>org.telegram.StoryPeerList</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>StoryPeerList</string>
"""
)
apple_resource_bundle(
name = "StoryPeerListBundle",
infoplists = [
":StoryPeerListBundleInfoPlist",
],
resources = [
":StoryPeerListMetalResources",
],
)
swift_library(
name = "StoryPeerListComponent",
module_name = "StoryPeerListComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
data = [
":StoryPeerListBundle",
],
deps = [
"//submodules/Display",
"//submodules/MetalEngine",
"//submodules/ComponentFlow",
"//submodules/AppBundle",
"//submodules/Components/BundleIconComponent",
"//submodules/AccountContext",
"//submodules/TelegramCore",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramPresentationData",
"//submodules/AvatarNode",
"//submodules/ContextUI",
"//submodules/TelegramUI/Components/Stories/StoryContainerScreen",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramUI/Components/EmojiStatusComponent",
"//submodules/Components/HierarchyTrackingLayer",
"//submodules/TelegramUI/Components/ChatListTitleView",
"//submodules/TelegramUI/Components/Utils/AnimatableProperty",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,109 @@
#include <metal_stdlib>
using namespace metal;
struct QuadVertexOut {
float4 position [[position]];
float2 uv;
};
constant static float2 quadVertices[6] = {
float2(0.0, 0.0),
float2(1.0, 0.0),
float2(0.0, 1.0),
float2(1.0, 0.0),
float2(0.0, 1.0),
float2(1.0, 1.0)
};
vertex QuadVertexOut cameraBlobVertex(
constant float4 &rect [[ buffer(0) ]],
uint vid [[ vertex_id ]]
) {
float2 quadVertex = quadVertices[vid];
QuadVertexOut out;
out.position = float4(rect.x + quadVertex.x * rect.z, rect.y + quadVertex.y * rect.w, 0.0, 1.0);
out.position.x = -1.0 + out.position.x * 2.0;
out.position.y = -1.0 + out.position.y * 2.0;
out.uv = quadVertex;
return out;
}
#define BindingDistance 0.45
#define AARadius 2.0
#define PersistenceFactor 1.1
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (a - b) / k, 0.0, 1.0);
h = pow(h, 0.6);
return mix(a, b, h) - k * h * (1.0 - h) * 1.0;
}
float sdfCircle(float2 uv, float2 position, float radius) {
return length(uv - position) - radius;
}
float dynamicBinding(float2 primaryPos, float2 secondaryPos) {
float distance = length(primaryPos - secondaryPos);
return BindingDistance * (1.0 + PersistenceFactor * smoothstep(0.0, 2.0, distance));
}
float map(float2 uv, float2 primaryParameters, float2 primaryOffset, float2 secondaryParameters, float2 secondaryOffset) {
float primary = sdfCircle(uv, primaryOffset, primaryParameters.x);
float secondary = sdfCircle(uv, secondaryOffset, secondaryParameters.x);
float bindDist = dynamicBinding(primaryOffset, secondaryOffset);
float metaballs = 1.0;
metaballs = smin(metaballs, primary, bindDist);
metaballs = smin(metaballs, secondary, bindDist);
return metaballs;
}
fragment half4 cameraBlobFragment(
QuadVertexOut in [[stage_in]],
constant float2 &primaryParameters [[buffer(0)]],
constant float2 &primaryOffset [[buffer(1)]],
constant float2 &secondaryParameters [[buffer(2)]],
constant float2 &secondaryOffset [[buffer(3)]],
constant float2 &resolution [[buffer(4)]]
) {
float aspectRatio = resolution.x / resolution.y;
float2 uv = in.uv * 2.0 - 1.0;
uv.x *= aspectRatio;
float t = AARadius / resolution.y;
float c = smoothstep(t, -t, map(uv, primaryParameters, primaryOffset, secondaryParameters, secondaryOffset));
if (primaryParameters.y > 0) {
float innerHoleRadius = primaryParameters.y;
float hole = smoothstep(-t, t, length(uv - primaryOffset) - innerHoleRadius);
float primaryInfluence = smoothstep(t, -t, sdfCircle(uv, primaryOffset, primaryParameters.x * 1.2));
c *= mix(1.0, hole, primaryInfluence);
} else if (primaryParameters.y < 0) {
float cutRadius = abs(primaryParameters.y);
float2 primaryFeatheredOffset = primaryOffset;
primaryFeatheredOffset.x *= 1.131;
float distFromCenter = length(uv - primaryFeatheredOffset);
float gradientWidth = 0.21;
float featheredEdge = smoothstep(cutRadius - gradientWidth, cutRadius + gradientWidth, distFromCenter);
float primaryInfluence = smoothstep(t, -t, sdfCircle(uv, primaryOffset, primaryParameters.x * 1.2));
float horizontalFade = 1.0;
float rightEdgePosition = 0.94 * aspectRatio;
if (uv.x > rightEdgePosition) {
horizontalFade = 1.0 - smoothstep(0.0, 0.22, uv.x - rightEdgePosition);
}
c *= mix(1.0, featheredEdge, primaryInfluence) * horizontalFade;
}
return half4(0.0, 0.0, 0.0, c);
}
@@ -0,0 +1,426 @@
import Foundation
import Display
import Metal
import MetalKit
import MetalEngine
import ComponentFlow
import TelegramPresentationData
import AnimatableProperty
private var metalLibraryValue: MTLLibrary?
func metalLibrary(device: MTLDevice) -> MTLLibrary? {
if let metalLibraryValue {
return metalLibraryValue
}
let mainBundle = Bundle(for: StoryBlobLayer.self)
guard let path = mainBundle.path(forResource: "StoryPeerListBundle", ofType: "bundle") else {
return nil
}
guard let bundle = Bundle(path: path) else {
return nil
}
guard let library = try? device.makeDefaultLibrary(bundle: bundle) else {
return nil
}
metalLibraryValue = library
return library
}
final class StoryBlobLayer: MetalEngineSubjectLayer, MetalEngineSubject {
var internalData: MetalEngineSubjectInternalData?
private final class RenderState: RenderToLayerState {
let pipelineState: MTLRenderPipelineState
required init?(device: MTLDevice) {
guard let library = metalLibrary(device: device) else {
return nil
}
guard let vertexFunction = library.makeFunction(name: "cameraBlobVertex"),
let fragmentFunction = library.makeFunction(name: "cameraBlobFragment") else {
return nil
}
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
pipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add
pipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add
pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
guard let pipelineState = try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) else {
return nil
}
self.pipelineState = pipelineState
}
}
private var primarySize = AnimatableProperty<CGFloat>(value: 1.0)
private var primaryHoleSize = AnimatableProperty<CGFloat>(value: 0.0)
private var primaryOffsetX = AnimatableProperty<CGFloat>(value: 45.0)
private var primaryOffsetY = AnimatableProperty<CGFloat>(value: 0.0)
private var secondarySize = AnimatableProperty<CGFloat>(value: 0.85)
private var secondaryOffsetX = AnimatableProperty<CGFloat>(value: 45.0)
private var secondaryOffsetY = AnimatableProperty<CGFloat>(value: 0.0)
private var displayLinkSubscription: SharedDisplayLinkDriver.Link?
private var hasActiveAnimations: Bool = false
override init() {
super.init()
self.isOpaque = false
self.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else {
return
}
self.updateAnimations()
if self.hasActiveAnimations {
self.setNeedsUpdate()
}
}
}
self.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.displayLinkSubscription = nil
}
}
override init(layer: Any) {
super.init(layer: layer)
if let layer = layer as? StoryBlobLayer {
self.primarySize = layer.primarySize
self.primaryHoleSize = layer.primaryHoleSize
self.primaryOffsetX = layer.primaryOffsetX
self.primaryOffsetY = layer.primaryOffsetY
self.secondarySize = layer.secondarySize
self.secondaryOffsetX = layer.secondaryOffsetX
self.secondaryOffsetY = layer.secondaryOffsetY
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updatePrimarySize(_ size: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.height > 0.0 else {
return
}
let mappedSize = size / self.bounds.height
self.primarySize.update(value: mappedSize, transition: transition)
self.setNeedsUpdate()
}
func updatePrimaryHoleSize(_ size: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.height > 0.0 else {
return
}
let mappedSize = size / self.bounds.height
self.primaryHoleSize.update(value: mappedSize, transition: transition)
self.setNeedsUpdate()
}
func updatePrimaryOffsetX(_ offset: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.height > 0.0 else {
return
}
let mappedOffset = offset / self.bounds.height * 2.0
self.primaryOffsetX.update(value: mappedOffset, transition: transition)
self.setNeedsUpdate()
}
func updatePrimaryOffsetY(_ offset: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.width > 0.0 else {
return
}
let mappedOffset = offset / self.bounds.width * 2.0
self.primaryOffsetY.update(value: mappedOffset, transition: transition)
self.setNeedsUpdate()
}
func updateSecondarySize(_ size: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.height > 0.0 else {
return
}
let mappedSize = size / self.bounds.height
self.secondarySize.update(value: mappedSize, transition: transition)
self.setNeedsUpdate()
}
func updateSecondaryOffsetX(_ offset: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.height > 0.0 else {
return
}
let mappedOffset = offset / self.bounds.height * 2.0
self.secondaryOffsetX.update(value: mappedOffset, transition: transition)
self.setNeedsUpdate()
}
func updateSecondaryOffsetY(_ offset: CGFloat, transition: ComponentTransition = .immediate) {
guard self.bounds.width > 0.0 else {
return
}
let mappedOffset = offset / self.bounds.width * 2.0
self.secondaryOffsetY.update(value: mappedOffset, transition: transition)
self.setNeedsUpdate()
}
private func updateAnimations() {
let properties = [
self.primarySize,
self.primaryHoleSize,
self.primaryOffsetX,
self.primaryOffsetY,
self.secondarySize,
self.secondaryOffsetX,
self.secondaryOffsetY,
]
let timestamp = CACurrentMediaTime()
var hasAnimations = false
for property in properties {
if property.tick(timestamp: timestamp) {
hasAnimations = true
}
}
self.hasActiveAnimations = hasAnimations
}
func update(context: MetalEngineSubjectContext) {
if self.bounds.isEmpty {
return
}
let drawableSize = CGSize(width: self.bounds.width * UIScreen.main.scale, height: self.bounds.height * UIScreen.main.scale)
context.renderToLayer(spec: RenderLayerSpec(size: RenderSize(width: Int(drawableSize.width), height: Int(drawableSize.height))), state: RenderState.self, layer: self, commands: { encoder, placement in
let effectiveRect = placement.effectiveRect
var rect = SIMD4<Float>(Float(effectiveRect.minX), Float(effectiveRect.minY), Float(effectiveRect.width), Float(effectiveRect.height))
encoder.setVertexBytes(&rect, length: 4 * 4, index: 0)
var primaryParameters = simd_float2(
Float(self.primarySize.presentationValue),
Float(self.primaryHoleSize.presentationValue)
)
encoder.setFragmentBytes(&primaryParameters, length: MemoryLayout<simd_float2>.size, index: 0)
var primaryOffset = simd_float2(
Float(self.primaryOffsetX.presentationValue),
Float(self.primaryOffsetY.presentationValue)
)
encoder.setFragmentBytes(&primaryOffset, length: MemoryLayout<simd_float2>.size, index: 1)
var secondaryParameters = simd_float2(
Float(self.secondarySize.presentationValue),
Float(0)
)
encoder.setFragmentBytes(&secondaryParameters, length: MemoryLayout<simd_float2>.size, index: 2)
var secondaryOffset = simd_float2(
Float(self.secondaryOffsetX.presentationValue),
Float(self.secondaryOffsetY.presentationValue)
)
encoder.setFragmentBytes(&secondaryOffset, length: MemoryLayout<simd_float2>.size, index: 3)
var resolution = simd_float2(
Float(drawableSize.width),
Float(drawableSize.height)
)
encoder.setFragmentBytes(&resolution, length: MemoryLayout<simd_float2>.size, index: 4)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
})
}
}
final class StoryComposeLayer: CALayer {
private let theme: PresentationTheme?
private let strings: PresentationStrings?
private let blobLayer = SimpleLayer()
private let maskLayer = StoryBlobLayer()
private let backgroundLayer = SimpleGradientLayer()
private let foregroundLayer = SimpleGradientLayer()
private let iconLayer = SimpleLayer()
private let labelLayer = SimpleLayer()
init(theme: PresentationTheme, strings: PresentationStrings) {
self.theme = theme
self.strings = strings
super.init()
self.addSublayer(self.blobLayer)
self.blobLayer.mask = self.maskLayer
self.blobLayer.masksToBounds = true
self.backgroundLayer.type = .axial
self.backgroundLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
self.backgroundLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
self.blobLayer.addSublayer(self.backgroundLayer)
self.foregroundLayer.type = .axial
self.foregroundLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.foregroundLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
self.blobLayer.addSublayer(self.foregroundLayer)
self.iconLayer.contents = generateAddIcon(color: theme.list.itemCheckColors.foregroundColor)?.cgImage
self.iconLayer.opacity = 0.0
self.blobLayer.addSublayer(self.iconLayer)
if let image = generateAddLabel(strings: strings, color: theme.list.itemPrimaryTextColor) {
self.labelLayer.contents = image.cgImage
self.labelLayer.bounds = CGRect(origin: .zero, size: image.size)
self.addSublayer(self.labelLayer)
self.labelLayer.opacity = 0.0
}
if let blurEffect = CALayer.blur() {
self.iconLayer.filters = [blurEffect]
blurEffect.setValue(8.0 as NSNumber, forKey: "inputRadius")
self.labelLayer.filters = [blurEffect]
blurEffect.setValue(8.0 as NSNumber, forKey: "inputRadius")
}
}
override init(layer: Any) {
if let layer = layer as? StoryComposeLayer {
self.theme = layer.theme
self.strings = layer.strings
} else {
self.theme = nil
self.strings = nil
}
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateOffset(_ offset: CGFloat, baseSize: CGFloat, colors: [CGColor], transition: ComponentTransition = .immediate) {
self.backgroundLayer.colors = colors
self.foregroundLayer.locations = [0.0, 0.68, 1.0]
if let theme = self.theme {
let fillColor = theme.list.itemCheckColors.fillColor
self.foregroundLayer.colors = [
fillColor.cgColor,
fillColor.withAlphaComponent(0.0).cgColor,
fillColor.withAlphaComponent(0.0).cgColor
]
}
var holeSize = baseSize > 52.0 ? 58.0 : -60.0
var primaryBaseScale = 1.0
var secondaryBaseScale = 0.68
if holeSize < 0.0 {
if offset > 48.0 {
holeSize -= min(14.0, (max(0.0, (offset - 48.0)) / 20.0) * 14.0)
}
primaryBaseScale = 0.96 + min(1.0, max(0.0, (offset - 20.0)) / 10.0) * 0.04
secondaryBaseScale = 0.62
}
var secondaryScale = secondaryBaseScale
if offset < 20.0 {
secondaryScale += (offset / 20.0) * 0.2
} else {
secondaryScale = min(1.0, max(secondaryBaseScale + 0.2, offset / 75.0))
}
self.maskLayer.updatePrimaryOffsetX(85.0, transition: transition)
self.maskLayer.updatePrimarySize(baseSize * primaryBaseScale, transition: transition)
self.maskLayer.updateSecondaryOffsetX(85.0 - offset, transition: transition)
self.maskLayer.updatePrimaryHoleSize(holeSize, transition: transition)
self.maskLayer.updateSecondarySize(55.0 * secondaryScale, transition: transition)
if holeSize < 0.0 {
transition.setAlpha(layer: self.blobLayer, alpha: min(1.0, max(0.0, offset / 10.0)))
}
let layerX = self.bounds.width - offset + 5.0
transition.setPosition(layer: self.iconLayer, position: CGPoint(x: layerX, y: self.bounds.height / 2.0))
transition.setPosition(layer: self.labelLayer, position: CGPoint(x: layerX, y: self.bounds.height + 13.0 - UIScreenPixel))
let iconOffset = max(0.0, offset - 35.0)
let alpha = max(0.0, min(1.0, iconOffset / 15.0))
let blurRadius = 8.0 - min(8.0, (iconOffset / 20.0) * 8.0)
for layer in [self.iconLayer, self.labelLayer] {
layer.setValue(blurRadius as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius")
transition.setAlpha(layer: layer, alpha: alpha)
}
}
override func layoutSublayers() {
super.layoutSublayers()
self.blobLayer.frame = self.bounds
self.maskLayer.frame = self.bounds
self.backgroundLayer.frame = self.bounds
self.foregroundLayer.frame = self.bounds
self.iconLayer.bounds = CGRect(origin: .zero, size: CGSize(width: 30.0, height: 30.0))
}
}
private func generateAddIcon(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in
context.clear(CGRect(origin: .zero, size: size))
context.setStrokeColor(color.cgColor)
context.setLineWidth(3.0)
context.setLineCap(.round)
context.move(to: CGPoint(x: 15.0, y: 5.5))
context.addLine(to: CGPoint(x: 15.0, y: 24.5))
context.strokePath()
context.move(to: CGPoint(x: 5.5, y: 15.0))
context.addLine(to: CGPoint(x: 24.5, y: 15.0))
context.strokePath()
})
}
private func generateAddLabel(strings: PresentationStrings, color: UIColor) -> UIImage? {
let titleString = NSAttributedString(string: strings.StoryFeed_AddStory, font: Font.regular(11.0), textColor: color, paragraphAlignment: .center)
var textRect = titleString.boundingRect(with: CGSize(width: 200.0, height: 20.0), options: .usesLineFragmentOrigin, context: nil)
textRect.size.width = ceil(textRect.size.width)
textRect.size.height = ceil(textRect.size.height)
return generateImage(textRect.size, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
UIGraphicsPushContext(context)
titleString.draw(in: textRect)
UIGraphicsPopContext()
})
}
@@ -0,0 +1,225 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import HierarchyTrackingLayer
public final class TitleActivityIndicatorComponent: Component {
let color: UIColor
public init(
color: UIColor
) {
self.color = color
}
public static func ==(lhs: TitleActivityIndicatorComponent, rhs: TitleActivityIndicatorComponent) -> Bool {
if lhs.color != rhs.color {
return false
}
return true
}
public final class View: UIView {
private let shapeLayer: SimpleShapeLayer
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private var component: TitleActivityIndicatorComponent?
private var animator: ConstantDisplayLinkAnimator?
private var animationPhase: CGFloat = 0.0
public override init(frame: CGRect) {
self.shapeLayer = SimpleShapeLayer()
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.hierarchyTrackingLayer.didExitHierarchy = { [weak self] in
guard let self else {
return
}
self.refreshAnimation()
}
self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.refreshAnimation()
}
self.layer.addSublayer(self.shapeLayer)
self.shapeLayer.lineCap = .round
self.shapeLayer.lineWidth = 1.5
self.shapeLayer.fillColor = nil
}
required public init?(coder: NSCoder) {
preconditionFailure()
}
deinit {
}
private func refreshAnimation() {
if self.hierarchyTrackingLayer.isInHierarchy {
if self.animator == nil {
let animationStartTime = CACurrentMediaTime()
self.animator = ConstantDisplayLinkAnimator(update: { [weak self] in
guard let self else {
return
}
let duration: Double = 0.5
self.animationPhase = (CACurrentMediaTime() - animationStartTime).truncatingRemainder(dividingBy: duration) / duration
self.updateAnimation()
})
self.animator?.isPaused = false
}
} else {
if let animator = self.animator {
self.animator = nil
animator.invalidate()
}
}
}
private func updateAnimation() {
let size = self.shapeLayer.bounds
let path = CGMutablePath()
let radius = size.height * 0.5
enum Segment {
case line(start: CGPoint, end: CGPoint)
case halfCircle(center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)
func length(radius: CGFloat) -> CGFloat {
switch self {
case let .line(start, end):
return abs(start.x - end.x)
case let .halfCircle(_, radius, startAngle, endAngle):
return (endAngle - startAngle) * radius
}
}
func addPath(into path: CGMutablePath, fromFraction: CGFloat, toFraction: CGFloat) {
switch self {
case let .line(start, end):
if fromFraction != 0.0 {
path.move(to: CGPoint(
x: start.x * (1.0 - fromFraction) + end.x * fromFraction,
y: start.y * (1.0 - fromFraction) + end.y * fromFraction
))
}
path.addLine(to: CGPoint(
x: start.x * (1.0 - toFraction) + end.x * toFraction,
y: start.y * (1.0 - toFraction) + end.y * toFraction
))
case let .halfCircle(center, radius, startAngle, endAngle):
path.addArc(center: center, radius: radius, startAngle: startAngle + fromFraction * (endAngle - startAngle), endAngle: startAngle + toFraction * (endAngle - startAngle), clockwise: false)
}
}
}
let segments: [Segment] = [
.halfCircle(center: CGPoint(x: size.width - radius, y: radius), radius: radius, startAngle: -CGFloat.pi * 0.5, endAngle: CGFloat.pi * 0.5),
.line(start: CGPoint(x: size.width - radius, y: size.height), end: CGPoint(x: radius, y: size.height)),
.halfCircle(center: CGPoint(x: radius, y: radius), radius: radius, startAngle: CGFloat.pi * 0.5, endAngle: CGFloat.pi * 1.5),
.line(start: CGPoint(x: radius, y: 0.0), end: CGPoint(x: size.width - radius, y: 0.0)),
]
var totalLength: CGFloat = 0.0
for segment in segments {
totalLength += segment.length(radius: radius)
}
let startOffset: CGFloat = self.animationPhase
let endOffset: CGFloat = startOffset + 0.8
var startLength = startOffset * totalLength
var startSegment: (Int, CGFloat)?
while startSegment == nil {
for i in 0 ..< segments.count {
let segment = segments[i]
let segmentLength = segment.length(radius: radius)
if segmentLength <= startLength {
startLength -= segmentLength
} else {
let subOffset = startLength
startSegment = (i, subOffset)
break
}
}
}
var isFirst = true
var pathLength = (endOffset - startOffset) * totalLength
if let (startIndex, startOffset) = startSegment {
var index = startIndex
while pathLength > 0.0 {
let segment = segments[index]
let segmentOffset: CGFloat = isFirst ? startOffset : 0.0
let segmentLength = segment.length(radius: radius)
let segmentSubLength = segmentLength - segmentOffset
if segmentSubLength > 0.0 {
//remainingLength <= segmentRemainingLength -> take remainingLength
//remainingLength > segmentRemainingLength -> take segmentRemainingLength
let pathPart: CGFloat
if pathLength <= segmentSubLength {
pathPart = pathLength
} else {
pathPart = segmentSubLength
}
pathLength -= pathPart
segment.addPath(into: path, fromFraction: segmentOffset / segmentLength, toFraction: (segmentOffset + pathPart) / segmentLength)
}
index = (index + 1) % segments.count
isFirst = false
}
}
/*for segment in segments {
segment.addPath(into: path, fromFraction: 0.0, toFraction: 1.0)
}*/
if let currentPath = self.shapeLayer.path {
if currentPath != path {
self.shapeLayer.path = path
}
} else {
self.shapeLayer.path = path
}
}
func update(component: TitleActivityIndicatorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
self.component = component
let _ = isFirstTime
transition.setFrame(layer: self.shapeLayer, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setShapeLayerPath(layer: self.shapeLayer, path: UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: availableSize), cornerRadius: availableSize.height * 0.5).cgPath)
self.shapeLayer.strokeColor = component.color.cgColor
self.refreshAnimation()
self.updateAnimation()
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,29 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StoryQualityUpgradeSheetScreen",
module_name = "StoryQualityUpgradeSheetScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramPresentationData",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/SheetComponent",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/TelegramUI/Components/LottieComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,425 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ViewControllerComponent
import AccountContext
import SheetComponent
import ButtonComponent
import LottieComponent
import MultilineTextComponent
import BalancedTextComponent
import Markdown
import TelegramStringFormatting
import BundleIconComponent
public final class ButtonSubtitleComponent: CombinedComponent {
public let title: String
public let color: UIColor
public init(title: String, color: UIColor) {
self.title = title
self.color = color
}
public static func ==(lhs: ButtonSubtitleComponent, rhs: ButtonSubtitleComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.color !== rhs.color {
return false
}
return true
}
public static var body: Body {
let icon = Child(BundleIconComponent.self)
let text = Child(Text.self)
return { context in
let icon = icon.update(
component: BundleIconComponent(
name: "Chat/Input/Accessory Panels/TextLockIcon",
tintColor: context.component.color,
maxSize: CGSize(width: 10.0, height: 10.0)
),
availableSize: CGSize(width: 100.0, height: 100.0),
transition: context.transition
)
let text = text.update(
component: Text(text: context.component.title, font: Font.medium(11.0), color: context.component.color),
availableSize: CGSize(width: context.availableSize.width - 20.0, height: 100.0),
transition: context.transition
)
let spacing: CGFloat = 3.0
let size = CGSize(width: icon.size.width + spacing + text.size.width, height: text.size.height)
context.add(icon
.position(icon.size.centered(in: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: icon.size.width, height: size.height))).center)
)
context.add(text
.position(text.size.centered(in: CGRect(origin: CGPoint(x: icon.size.width + spacing, y: 0.0), size: text.size)).center)
)
return size
}
}
}
private final class StoryQualityUpgradeSheetContentComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let action: () -> Void
let dismiss: () -> Void
init(
action: @escaping () -> Void,
dismiss: @escaping () -> Void
) {
self.action = action
self.dismiss = dismiss
}
static func ==(lhs: StoryQualityUpgradeSheetContentComponent, rhs: StoryQualityUpgradeSheetContentComponent) -> Bool {
return true
}
final class View: UIView {
private let icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private let button = ComponentView<Empty>()
private var cancelButton: ComponentView<Empty>?
private var component: StoryQualityUpgradeSheetContentComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: StoryQualityUpgradeSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let environment = environment[EnvironmentType.self].value
let sideInset: CGFloat = 16.0
let cancelButton: ComponentView<Empty>
if let current = self.cancelButton {
cancelButton = current
} else {
cancelButton = ComponentView()
self.cancelButton = cancelButton
}
let cancelButtonSize = cancelButton.update(
transition: transition,
component: AnyComponent(Button(
content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.dismiss()
}
).minSize(CGSize(width: 8.0, height: 44.0))),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
if let cancelButtonView = cancelButton.view {
if cancelButtonView.superview == nil {
self.addSubview(cancelButtonView)
}
transition.setFrame(view: cancelButtonView, frame: CGRect(origin: CGPoint(x: 16.0, y: 6.0), size: cancelButtonSize))
}
var contentHeight: CGFloat = 0.0
contentHeight += 32.0
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "StoryUpgradeSheet"),
color: nil,
startingPosition: .begin,
size: CGSize(width: 100.0, height: 100.0)
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: 42.0), size: iconSize))
}
contentHeight += 138.0
let titleSize = self.title.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: environment.strings.Story_UpgradeQuality_Title, font: Font.semibold(20.0), textColor: environment.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize))
}
contentHeight += titleSize.height
contentHeight += 14.0
let textSize = self.text.update(
transition: transition,
component: AnyComponent(BalancedTextComponent(
text: .plain(NSAttributedString(string: environment.strings.Story_UpgradeQuality_Text, font: Font.regular(14.0), textColor: environment.theme.list.itemSecondaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.18
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let textView = self.text.view {
if textView.superview == nil {
self.addSubview(textView)
}
transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - textSize.width) * 0.5), y: contentHeight), size: textSize))
}
contentHeight += textSize.height
contentHeight += 12.0
contentHeight += 32.0
var buttonContents: [AnyComponentWithIdentity<Empty>] = []
buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(
Text(text: environment.strings.Story_UpgradeQuality_Action, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor)
)))
buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(ButtonSubtitleComponent(
title: environment.strings.Story_UpgradeQuality_ActionSubtitle,
color: environment.theme.list.itemCheckColors.foregroundColor.withMultipliedAlpha(0.7)
))))
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.8)
),
content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(
VStack(buttonContents, spacing: 3.0)
)),
isEnabled: true,
allowActionWhenDisabled: true,
displaysProgress: false,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.action()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: buttonSize)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: buttonFrame)
}
contentHeight += buttonSize.height
if environment.safeInsets.bottom.isZero {
contentHeight += 16.0
} else {
contentHeight += environment.safeInsets.bottom + 14.0
}
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
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)
}
}
private final class StoryQualityUpgradeSheetScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let buttonAction: (() -> Void)?
init(
context: AccountContext,
buttonAction: (() -> Void)?
) {
self.context = context
self.buttonAction = buttonAction
}
static func ==(lhs: StoryQualityUpgradeSheetScreenComponent, rhs: StoryQualityUpgradeSheetScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>()
private let sheetAnimateOut = ActionSlot<Action<Void>>()
private var component: StoryQualityUpgradeSheetScreenComponent?
private var environment: EnvironmentType?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryQualityUpgradeSheetScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.component = component
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
let sheetEnvironment = SheetComponentEnvironment(
isDisplaying: environment.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.sheetAnimateOut.invoke(Action { _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
})
}
)
let _ = self.sheet.update(
transition: transition,
component: AnyComponent(SheetComponent(
content: AnyComponent(StoryQualityUpgradeSheetContentComponent(
action: { [weak self] in
guard let self else {
return
}
self.sheetAnimateOut.invoke(Action { [weak self] _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
guard let self else {
return
}
self.component?.buttonAction?()
})
},
dismiss: {
self.sheetAnimateOut.invoke(Action { _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
})
}
)),
backgroundColor: .color(environment.theme.overallDarkAppearance ? environment.theme.list.itemBlocksBackgroundColor : environment.theme.list.blocksBackgroundColor),
animateOut: self.sheetAnimateOut
)),
environment: {
environment
sheetEnvironment
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public class StoryQualityUpgradeSheetScreen: ViewControllerComponentContainer {
public init(
context: AccountContext,
buttonAction: (() -> Void)? = nil
) {
super.init(context: context, component: StoryQualityUpgradeSheetScreenComponent(
context: context,
buttonAction: buttonAction
), navigationBarAppearance: .none, theme: .dark)
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.view.disablesInteractiveModalDismiss = true
}
override public func dismiss(completion: (() -> Void)? = nil) {
super.dismiss(completion: {
completion?()
})
self.wasDismissed?()
}
}
@@ -0,0 +1,26 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StorySetIndicatorComponent",
module_name = "StorySetIndicatorComponent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramPresentationData",
"//submodules/AvatarNode",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/Postbox",
"//submodules/TelegramCore",
"//submodules/PhotoResources",
"//submodules/AccountContext",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,482 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import Postbox
import SwiftSignalKit
import AccountContext
import PhotoResources
import AvatarNode
private final class ShapeImageView: UIView {
struct Item: Equatable {
var position: CGPoint
var diameter: CGFloat
var image: UIImage?
}
struct Params: Equatable {
var items: [Item]
var innerSpacing: CGFloat
var lineWidth: CGFloat
var borderColors: [UInt32]
}
var params: Params?
override func draw(_ rect: CGRect) {
guard let params = self.params else {
return
}
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(rect)
context.setFillColor(UIColor.black.cgColor)
for item in params.items {
context.fillEllipse(in: CGRect(origin: CGPoint(x: item.position.x - item.diameter * 0.5, y: item.position.y - item.diameter * 0.5), size: CGSize(width: item.diameter, height: item.diameter)))
}
context.setFillColor(UIColor.clear.cgColor)
for item in params.items {
context.fillEllipse(in: CGRect(origin: CGPoint(x: item.position.x - item.diameter * 0.5, y: item.position.y - item.diameter * 0.5), size: CGSize(width: item.diameter, height: item.diameter)).insetBy(dx: params.lineWidth, dy: params.lineWidth))
}
context.setBlendMode(.sourceIn)
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: params.borderColors.map {
UIColor(argb: $0).cgColor
} as CFArray, locations: nil)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: 50.0), options: [])
context.setBlendMode(.copy)
for i in (0 ..< params.items.count).reversed() {
let item = params.items[i]
if i != params.items.count - 1 {
let previousItem = params.items[i]
context.setFillColor(UIColor.clear.cgColor)
context.setBlendMode(.copy)
context.fillEllipse(in: CGRect(origin: CGPoint(x: previousItem.position.x - previousItem.diameter * 0.5, y: previousItem.position.y - previousItem.diameter * 0.5), size: CGSize(width: previousItem.diameter, height: previousItem.diameter)).insetBy(dx: params.lineWidth, dy: params.lineWidth))
}
context.setBlendMode(.normal)
let imageRect = CGRect(origin: CGPoint(x: item.position.x - item.diameter * 0.5, y: item.position.y - item.diameter * 0.5), size: CGSize(width: item.diameter, height: item.diameter)).insetBy(dx: params.lineWidth + params.innerSpacing, dy: params.lineWidth + params.innerSpacing)
if let image = item.image {
context.translateBy(x: imageRect.midX, y: imageRect.midY)
context.scaleBy(x: 1.0, y: 1.0)
context.translateBy(x: -imageRect.midX, y: -imageRect.midY)
image.draw(in: imageRect, blendMode: .normal, alpha: 1.0)
context.translateBy(x: imageRect.midX, y: imageRect.midY)
context.scaleBy(x: 1.0, y: 1.0)
context.translateBy(x: -imageRect.midX, y: -imageRect.midY)
} else {
context.setFillColor(UIColor.black.cgColor)
context.fillEllipse(in: imageRect)
}
}
}
}
public final class StorySetIndicatorComponent: Component {
public final class Item: Equatable {
public let storyItem: EngineStoryItem
public let peer: EnginePeer
public init(storyItem: EngineStoryItem, peer: EnginePeer) {
self.storyItem = storyItem
self.peer = peer
}
public static func ==(lhs: Item, rhs: Item) -> Bool {
if lhs === rhs {
return true
}
if lhs.storyItem != rhs.storyItem {
return false
}
if lhs.peer != rhs.peer {
return false
}
return true
}
var id: String {
return "\(self.peer.id.toInt64())_\(self.storyItem.id)"
}
}
public let context: AccountContext
public let strings: PresentationStrings
public let items: [Item]
public let displayAvatars: Bool
public let hasUnseen: Bool
public let hasUnseenPrivate: Bool
public let totalCount: Int
public let theme: PresentationTheme
public let action: () -> Void
public init(
context: AccountContext,
strings: PresentationStrings,
items: [Item],
displayAvatars: Bool,
hasUnseen: Bool,
hasUnseenPrivate: Bool,
totalCount: Int,
theme: PresentationTheme,
action: @escaping () -> Void
) {
self.context = context
self.strings = strings
self.items = items
self.displayAvatars = displayAvatars
self.hasUnseen = hasUnseen
self.hasUnseenPrivate = hasUnseenPrivate
self.totalCount = totalCount
self.theme = theme
self.action = action
}
public static func ==(lhs: StorySetIndicatorComponent, rhs: StorySetIndicatorComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.items != rhs.items {
return false
}
if lhs.displayAvatars != rhs.displayAvatars {
return false
}
if lhs.hasUnseen != rhs.hasUnseen {
return false
}
if lhs.hasUnseenPrivate != rhs.hasUnseenPrivate {
return false
}
if lhs.totalCount != rhs.totalCount {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
private final class ImageContext {
private var fetchDisposable: Disposable?
private var imageDisposable: Disposable?
private let updated: () -> Void
private(set) var image: UIImage?
init(context: AccountContext, item: StorySetIndicatorComponent.Item, displayAvatars: Bool, updated: @escaping () -> Void) {
self.updated = updated
let peerReference = PeerReference(item.peer._asPeer())
var messageMedia: EngineMedia?
switch item.storyItem.media {
case let .image(image):
messageMedia = .image(image)
case let .file(file):
messageMedia = .file(file)
default:
break
}
let reloadMedia = true
if reloadMedia, let messageMedia, let peerReference {
var imageSignal: Signal<UIImage?, NoError>?
var signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
var fetchSignal: Signal<Never, NoError>?
if displayAvatars {
imageSignal = peerAvatarCompleteImage(postbox: context.account.postbox, network: context.account.network, peer: item.peer, forceProvidedRepresentation: false, representation: nil, size: CGSize(width: 26.0, height: 26.0), round: true, font: avatarPlaceholderFont(size: 13.0), drawLetters: true, fullSize: false, blurred: false)
} else {
switch messageMedia {
case let .image(image):
signal = chatMessagePhoto(
postbox: context.account.postbox,
userLocation: .peer(peerReference.id),
userContentType: .story,
photoReference: .story(peer: peerReference, id: item.storyItem.id, media: image),
synchronousLoad: false,
highQuality: true
)
if let representation = image.representations.last {
fetchSignal = fetchedMediaResource(
mediaBox: context.account.postbox.mediaBox,
userLocation: .peer(peerReference.id),
userContentType: .story,
reference: ImageMediaReference.story(peer: peerReference, id: item.storyItem.id, media: image).resourceReference(representation.resource)
)
|> ignoreValues
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
}
case let .file(file):
signal = mediaGridMessageVideo(
postbox: context.account.postbox,
userLocation: .peer(peerReference.id),
userContentType: .story,
videoReference: .story(peer: peerReference, id: item.storyItem.id, media: file),
onlyFullSize: false,
useLargeThumbnail: true,
synchronousLoad: false,
autoFetchFullSizeThumbnail: true,
overlayColor: nil,
nilForEmptyResult: false,
useMiniThumbnailIfAvailable: false,
blurred: false
)
fetchSignal = fetchedMediaResource(
mediaBox: context.account.postbox.mediaBox,
userLocation: .peer(peerReference.id),
userContentType: .story,
reference: FileMediaReference.story(peer: peerReference, id: item.storyItem.id, media: file).resourceReference(file.resource)
)
|> ignoreValues
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
default:
break
}
}
if let imageSignal {
var wasSynchronous = true
self.imageDisposable = (imageSignal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
if let result {
self.image = result
if !wasSynchronous {
self.updated()
}
}
})
wasSynchronous = false
} else if let signal {
var wasSynchronous = true
self.imageDisposable = (signal
|> deliverOnMainQueue).start(next: { [weak self] process in
guard let self else {
return
}
let outerSize = CGSize(width: 1080.0, height: 1920.0)
let innerSize = CGSize(width: 26.0, height: 26.0)
let result = process(TransformImageArguments(corners: ImageCorners(radius: innerSize.width * 0.5), imageSize: outerSize.aspectFilled(innerSize), boundingSize: innerSize, intrinsicInsets: UIEdgeInsets()))
if let result {
self.image = result.generateImage()
if !wasSynchronous {
self.updated()
}
}
})
wasSynchronous = false
}
self.fetchDisposable?.dispose()
self.fetchDisposable = nil
if let fetchSignal {
self.fetchDisposable = (fetchSignal |> deliverOnMainQueue).start()
}
}
}
deinit {
self.fetchDisposable?.dispose()
self.imageDisposable?.dispose()
}
}
public final class View: UIView {
private let button: HighlightTrackingButton
private let imageView: ShapeImageView
private let text = ComponentView<Empty>()
private var imageContexts: [String: ImageContext] = [:]
private var component: StorySetIndicatorComponent?
private weak var state: EmptyComponentState?
private var effectiveItemsWidth: CGFloat = 0.0
public var transitionView: (UIView, CGRect) {
return (self.imageView, CGRect(origin: CGPoint(), size: CGSize(width: self.effectiveItemsWidth, height: self.imageView.bounds.height)))
}
override init(frame: CGRect) {
self.button = HighlightTrackingButton()
self.imageView = ShapeImageView(frame: CGRect())
self.imageView.isUserInteractionEnabled = false
self.imageView.backgroundColor = nil
self.imageView.isOpaque = false
super.init(frame: frame)
self.button.addSubview(self.imageView)
self.addSubview(self.button)
self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.button.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
let transition = ComponentTransition(animation: .curve(duration: 0.16, curve: .easeInOut))
transition.setSublayerTransform(view: self.button, transform: CATransform3DMakeScale(0.8, 0.8, 1.0))
} else {
let transition = ComponentTransition(animation: .curve(duration: 0.24, curve: .easeInOut))
transition.setSublayerTransform(view: self.button, transform: CATransform3DIdentity)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
self.component?.action()
}
func update(component: StorySetIndicatorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let innerDiameter: CGFloat = 26.0
let innerSpacing: CGFloat = 1.33
let lineWidth: CGFloat = 1.33
let outerDiameter: CGFloat = innerDiameter + innerSpacing * 2.0 + lineWidth * 2.0
let overflow: CGFloat = 14.0
var validIds: [String] = []
var items: [ShapeImageView.Item] = []
for i in 0 ..< min(3, component.items.count) {
validIds.append(component.items[i].id)
let imageContext: ImageContext
if let current = self.imageContexts[component.items[i].id] {
imageContext = current
} else {
var update = false
imageContext = ImageContext(context: component.context, item: component.items[i], displayAvatars: component.displayAvatars, updated: { [weak self] in
guard let self else {
return
}
if update {
self.state?.updated(transition: .immediate)
}
})
self.imageContexts[component.items[i].id] = imageContext
update = true
}
items.append(ShapeImageView.Item(
position: CGPoint(x: outerDiameter * 0.5 + CGFloat(i) * (outerDiameter - overflow), y: outerDiameter * 0.5),
diameter: outerDiameter,
image: imageContext.image
))
}
var removeIds: [String] = []
for (id, _) in self.imageContexts {
if !validIds.contains(id) {
removeIds.append(id)
}
}
for id in removeIds {
self.imageContexts.removeValue(forKey: id)
}
let maxItemsWidth: CGFloat = outerDiameter * 0.5 + CGFloat(max(0, 3 - 1)) * (outerDiameter - overflow) + outerDiameter * 0.5
let effectiveItemsWidth: CGFloat = outerDiameter * 0.5 + CGFloat(max(0, items.count - 1)) * (outerDiameter - overflow) + outerDiameter * 0.5
self.effectiveItemsWidth = effectiveItemsWidth
let borderColors: [UInt32]
if component.hasUnseenPrivate {
borderColors = [component.theme.chatList.storyUnseenPrivateColors.topColor.argb, component.theme.chatList.storyUnseenPrivateColors.bottomColor.argb]
} else if component.hasUnseen {
borderColors = [component.theme.chatList.storyUnseenColors.topColor.argb, component.theme.chatList.storyUnseenColors.bottomColor.argb]
} else {
borderColors = [UIColor(white: 1.0, alpha: 0.3).argb, UIColor(white: 1.0, alpha: 0.3).argb]
}
let imageSize = CGSize(width: maxItemsWidth, height: outerDiameter)
let params = ShapeImageView.Params(
items: items,
innerSpacing: innerSpacing,
lineWidth: lineWidth,
borderColors: borderColors
)
if self.imageView.params != params || self.imageView.bounds.size != imageSize {
self.imageView.params = params
self.imageView.frame = CGRect(origin: CGPoint(), size: imageSize)
self.imageView.setNeedsDisplay()
}
let textValue: String
if component.totalCount == 0 {
textValue = ""
} else {
textValue = component.strings.Profile_AvatarStoryCount(Int32(component.totalCount))
}
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(Text(text: textValue, font: Font.semibold(17.0), color: .white)),
environment: {},
containerSize: CGSize(width: 300.0, height: 100.0)
)
let textFrame = CGRect(origin: CGPoint(x: effectiveItemsWidth + 6.0, y: 5.0), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.layer.anchorPoint = CGPoint()
textView.isUserInteractionEnabled = false
self.button.addSubview(textView)
}
transition.setPosition(view: textView, position: textFrame.origin)
textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
}
var width = effectiveItemsWidth
if textSize.width > 0.0 {
width += textSize.width + 6.0
}
let size = CGSize(width: width, height: outerDiameter)
transition.setFrame(view: self.button, frame: CGRect(origin: CGPoint(), size: size))
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,33 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StoryStealthModeSheetScreen",
module_name = "StoryStealthModeSheetScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/ComponentFlow",
"//submodules/TelegramPresentationData",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/SheetComponent",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/TelegramUI/Components/ToastComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/GlassBarButtonComponent",
"//submodules/Markdown",
"//submodules/TelegramStringFormatting",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,319 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import BundleIconComponent
import Markdown
import TelegramCore
import BalancedTextComponent
public final class StoryStealthModeInfoContentComponent: Component {
public let theme: PresentationTheme
public let strings: PresentationStrings
public let backwardDuration: Int32
public let forwardDuration: Int32
public let mode: StoryStealthModeSheetScreen.Mode
public let dismiss: () -> Void
public init(
theme: PresentationTheme,
strings: PresentationStrings,
backwardDuration: Int32,
forwardDuration: Int32,
mode: StoryStealthModeSheetScreen.Mode,
dismiss: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.backwardDuration = backwardDuration
self.forwardDuration = forwardDuration
self.mode = mode
self.dismiss = dismiss
}
public static func ==(lhs: StoryStealthModeInfoContentComponent, rhs: StoryStealthModeInfoContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.backwardDuration != rhs.backwardDuration {
return false
}
if lhs.forwardDuration != rhs.forwardDuration {
return false
}
if lhs.mode != rhs.mode {
return false
}
return true
}
private final class Item {
let icon = ComponentView<Empty>()
let title = ComponentView<Empty>()
let text = ComponentView<Empty>()
init() {
}
}
public final class View: UIView {
private let scrollView: UIScrollView
private let iconBackground: UIImageView
private let iconForeground: UIImageView
private let title = ComponentView<Empty>()
private let mainText = ComponentView<Empty>()
private var items: [Item] = []
private var component: StoryStealthModeInfoContentComponent?
public override init(frame: CGRect) {
self.scrollView = UIScrollView()
self.iconBackground = UIImageView()
self.iconForeground = UIImageView()
super.init(frame: frame)
self.addSubview(self.scrollView)
self.scrollView.delaysContentTouches = false
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.clipsToBounds = false
self.scrollView.addSubview(self.iconBackground)
self.scrollView.addSubview(self.iconForeground)
}
required init(coder: NSCoder) {
preconditionFailure()
}
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let result = super.hitTest(point, with: event) {
return result
} else {
return nil
}
}
func update(component: StoryStealthModeInfoContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let sideInset: CGFloat = 16.0
let sideIconInset: CGFloat = 40.0
var contentHeight: CGFloat = 0.0
let iconSize: CGFloat = 90.0
if self.iconBackground.image == nil {
let backgroundColors: [UIColor] = [UIColor(rgb: 0x3DA1FD), UIColor(rgb: 0x34C76F)]
let colors: NSArray = [backgroundColors[0].cgColor, backgroundColors[1].cgColor]
self.iconBackground.image = generateGradientFilledCircleImage(diameter: iconSize, colors: colors)
}
let iconBackgroundFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize) * 0.5), y: contentHeight), size: CGSize(width: iconSize, height: iconSize))
transition.setFrame(view: self.iconBackground, frame: iconBackgroundFrame)
if self.iconForeground.image == nil {
self.iconForeground.image = generateTintedImage(image: UIImage(bundleImageName: "Stories/StealthModeIntroIconMain"), color: .white)
}
if let image = self.iconForeground.image {
transition.setFrame(view: self.iconForeground, frame: CGRect(origin: CGPoint(x: iconBackgroundFrame.minX + floor((iconBackgroundFrame.width - image.size.width) * 0.5), y: iconBackgroundFrame.minY + floor((iconBackgroundFrame.height - image.size.height) * 0.5)), size: image.size))
}
contentHeight += iconSize
contentHeight += 15.0
let titleString = NSMutableAttributedString()
titleString.append(NSAttributedString(string: component.strings.Story_StealthMode_Title, font: Font.semibold(19.0), textColor: component.theme.list.itemPrimaryTextColor))
let imageAttachment = NSTextAttachment()
imageAttachment.image = self.iconBackground.image
titleString.append(NSAttributedString(attachment: imageAttachment))
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(titleString),
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.scrollView.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize))
}
contentHeight += titleSize.height
contentHeight += 15.0
let text: String
switch component.mode {
case .control:
text = component.strings.Story_StealthMode_ControlText
case .upgrade:
text = component.strings.Story_StealthMode_UpgradeText
}
let mainText = NSMutableAttributedString()
mainText.append(parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(
font: Font.regular(15.0),
textColor: component.theme.list.itemSecondaryTextColor
),
bold: MarkdownAttributeSet(
font: Font.semibold(15.0),
textColor: component.theme.list.itemSecondaryTextColor
),
link: MarkdownAttributeSet(
font: Font.regular(15.0),
textColor: component.theme.list.itemAccentColor,
additionalAttributes: [:]
),
linkAttribute: { attributes in
return ("URL", "")
}
)))
let mainTextSize = self.mainText.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(mainText),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let mainTextView = self.mainText.view {
if mainTextView.superview == nil {
self.scrollView.addSubview(mainTextView)
}
transition.setFrame(view: mainTextView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize))
}
contentHeight += mainTextSize.height
contentHeight += 24.0
struct ItemDesc {
var icon: String
var title: String
var text: String
}
let itemDescs: [ItemDesc] = [
ItemDesc(
icon: "Stories/StealthModeIntroIconHidePrevious",
title: component.strings.Story_StealthMode_RecentTitle,
text: component.strings.Story_StealthMode_RecentText(timeIntervalString(strings: component.strings, value: component.backwardDuration)).string
),
ItemDesc(
icon: "Stories/StealthModeIntroIconHideNext",
title: component.strings.Story_StealthMode_NextTitle,
text: component.strings.Story_StealthMode_NextText(timeIntervalString(strings: component.strings, value: component.forwardDuration)).string
)
]
for i in 0 ..< itemDescs.count {
if i != 0 {
contentHeight += 24.0
}
let item: Item
if self.items.count > i {
item = self.items[i]
} else {
item = Item()
self.items.append(item)
}
let itemDesc = itemDescs[i]
let iconSize = item.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: itemDesc.icon,
tintColor: component.theme.list.itemAccentColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let titleSize = item.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(15.0), textColor: component.theme.list.itemPrimaryTextColor)),
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sideIconInset, height: 1000.0)
)
let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)
let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: component.theme.list.itemSecondaryTextColor)
let textSize = item.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .markdown(text: itemDesc.text, attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in nil })),
maximumNumberOfLines: 0,
lineSpacing: 0.18
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sideIconInset, height: 1000.0)
)
if let iconView = item.icon.view {
if iconView.superview == nil {
self.scrollView.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 4.0), size: iconSize))
}
if let titleView = item.title.view {
if titleView.superview == nil {
self.scrollView.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: titleSize))
}
contentHeight += titleSize.height
contentHeight += 2.0
if let textView = item.text.view {
if textView.superview == nil {
self.scrollView.addSubview(textView)
}
transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: textSize))
}
contentHeight += textSize.height
}
let contentSize = CGSize(width: availableSize.width, height: contentHeight)
let size = CGSize(width: availableSize.width, height: min(availableSize.height, contentSize.height))
if self.scrollView.bounds.size != size || self.scrollView.contentSize != contentSize {
self.scrollView.frame = CGRect(origin: CGPoint(), size: size)
self.scrollView.contentSize = contentSize
}
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,531 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ViewControllerComponent
import AccountContext
import SheetComponent
import ButtonComponent
import ToastComponent
import LottieComponent
import MultilineTextComponent
import BundleIconComponent
import Markdown
import TelegramStringFormatting
import GlassBarButtonComponent
private final class StoryStealthModeSheetContentComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let mode: StoryStealthModeSheetScreen.Mode
let backwardDuration: Int32
let forwardDuration: Int32
let action: () -> Void
let dismiss: () -> Void
init(
mode: StoryStealthModeSheetScreen.Mode,
backwardDuration: Int32,
forwardDuration: Int32,
action: @escaping () -> Void,
dismiss: @escaping () -> Void
) {
self.mode = mode
self.backwardDuration = backwardDuration
self.forwardDuration = forwardDuration
self.action = action
self.dismiss = dismiss
}
static func ==(lhs: StoryStealthModeSheetContentComponent, rhs: StoryStealthModeSheetContentComponent) -> Bool {
if lhs.mode != rhs.mode {
return false
}
if lhs.backwardDuration != rhs.backwardDuration {
return false
}
if lhs.forwardDuration != rhs.forwardDuration {
return false
}
return true
}
final class View: UIView {
private var toast: ComponentView<Empty>?
private let content = ComponentView<Empty>()
private let button = ComponentView<Empty>()
private var cancelButton: ComponentView<Empty>?
private var component: StoryStealthModeSheetContentComponent?
private weak var state: EmptyComponentState?
private var timer: Timer?
private var showCooldownToast: Bool = false
private var hideCooldownTimer: Timer?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.timer?.invalidate()
self.hideCooldownTimer?.invalidate()
}
private func displayCooldown() {
if !self.showCooldownToast {
self.showCooldownToast = true
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.25, curve: .easeInOut)))
}
self.hideCooldownTimer?.invalidate()
self.hideCooldownTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
self.hideCooldownTimer?.invalidate()
self.hideCooldownTimer = nil
self.showCooldownToast = false
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.25, curve: .easeInOut)))
})
}
func update(component: StoryStealthModeSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
var remainingCooldownSeconds: Int32 = 0
if case let .control(_, cooldownUntilTimestamp) = component.mode {
if let cooldownUntilTimestamp {
remainingCooldownSeconds = cooldownUntilTimestamp - Int32(Date().timeIntervalSince1970)
remainingCooldownSeconds = max(0, remainingCooldownSeconds)
}
}
if remainingCooldownSeconds > 0 {
if self.timer == nil {
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
self.state?.updated(transition: .immediate)
})
}
} else {
if let timer = self.timer {
self.timer = nil
timer.invalidate()
}
}
let environment = environment[EnvironmentType.self].value
let sideInset: CGFloat = 16.0
if remainingCooldownSeconds > 0, self.showCooldownToast {
let toast: ComponentView<Empty>
var toastTransition = transition
if let current = self.toast {
toast = current
} else {
toastTransition = toastTransition.withAnimation(.none)
toast = ComponentView()
self.toast = toast
}
let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white)
let bold = MarkdownAttributeSet(font: Font.semibold(14.0), textColor: .white)
let toastSize = toast.update(
transition: toastTransition,
component: AnyComponent(ToastContentComponent(
icon: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "anim_infotip"),
startingPosition: .begin,
size: CGSize(width: 32.0, height: 32.0)
)),
content: AnyComponent(MultilineTextComponent(
text: .markdown(text: environment.strings.Story_StealthMode_ToastCooldownText, attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in nil })),
maximumNumberOfLines: 0
))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
if let toastView = toast.view {
if toastView.superview == nil {
self.addSubview(toastView)
toastView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
if let toastView = toastView as? ToastContentComponent.View, let iconView = toastView.iconView as? LottieComponent.View {
iconView.playOnce()
}
}
toastTransition.setFrame(view: toastView, frame: CGRect(origin: CGPoint(x: sideInset, y: -sideInset - toastSize.height), size: toastSize))
}
} else {
if let toast = self.toast {
self.toast = nil
if let toastView = toast.view {
toastView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak toastView] _ in
toastView?.removeFromSuperview()
})
}
}
}
var contentHeight: CGFloat = 0.0
contentHeight += 32.0
let contentSize = self.content.update(
transition: transition,
component: AnyComponent(StoryStealthModeInfoContentComponent(
theme: environment.theme,
strings: environment.strings,
backwardDuration: component.backwardDuration,
forwardDuration: component.forwardDuration,
mode: component.mode,
dismiss: { [weak self] in
guard let self, let component = self.component else {
return
}
component.dismiss()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height)
)
if let contentView = self.content.view {
if contentView.superview == nil {
self.addSubview(contentView)
}
transition.setFrame(view: contentView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: contentSize))
}
contentHeight += contentSize.height
contentHeight += 41.0
if case .upgrade = component.mode {
let cancelButton: ComponentView<Empty>
if let current = self.cancelButton {
cancelButton = current
} else {
cancelButton = ComponentView()
self.cancelButton = cancelButton
}
let cancelButtonSize = cancelButton.update(
transition: transition,
component: AnyComponent(GlassBarButtonComponent(
size: CGSize(width: 40.0, height: 40.0),
backgroundColor: environment.theme.rootController.navigationBar.glassBarButtonBackgroundColor,
isDark: environment.theme.overallDarkAppearance,
state: .generic,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: environment.theme.rootController.navigationBar.glassBarButtonForegroundColor
)
)),
action: { _ in
component.dismiss()
}
)),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
if let cancelButtonView = cancelButton.view {
if cancelButtonView.superview == nil {
self.addSubview(cancelButtonView)
}
transition.setFrame(view: cancelButtonView, frame: CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: cancelButtonSize))
}
} else if let cancelButton = self.cancelButton {
self.cancelButton = nil
cancelButton.view?.removeFromSuperview()
}
let buttonText: String
let content: AnyComponentWithIdentity<Empty>
switch component.mode {
case let .control(external, _):
if remainingCooldownSeconds <= 0 {
buttonText = external ? environment.strings.Story_StealthMode_EnableAndOpenAction : environment.strings.Story_StealthMode_EnableAction
} else {
buttonText = environment.strings.Story_StealthMode_CooldownAction(stringForDuration(remainingCooldownSeconds)).string
}
content = AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(Text(text: buttonText, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor)))
case .upgrade:
buttonText = environment.strings.Story_StealthMode_UpgradeAction
content = AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(
HStack([
AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(Text(text: buttonText, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor))),
AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "premium_unlock"),
color: environment.theme.list.itemCheckColors.foregroundColor,
startingPosition: .begin,
size: CGSize(width: 30.0, height: 30.0),
loop: true
)))
], spacing: 4.0)
))
}
let buttonSideInset: CGFloat = 30.0
let buttonSize = self.button.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: environment.theme.list.itemCheckColors.fillColor,
foreground: environment.theme.list.itemCheckColors.foregroundColor,
pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8)
),
content: content,
isEnabled: remainingCooldownSeconds <= 0,
allowActionWhenDisabled: true,
displaysProgress: false,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
switch component.mode {
case let .control(_, cooldownUntilTimestamp):
var remainingCooldownSeconds: Int32 = 0
if let cooldownUntilTimestamp {
remainingCooldownSeconds = cooldownUntilTimestamp - Int32(Date().timeIntervalSince1970)
remainingCooldownSeconds = max(0, remainingCooldownSeconds)
}
if remainingCooldownSeconds > 0 {
self.displayCooldown()
} else {
component.action()
}
case .upgrade:
component.action()
}
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - buttonSideInset * 2.0, height: 52.0)
)
let buttonFrame = CGRect(origin: CGPoint(x: buttonSideInset, y: contentHeight), size: buttonSize)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: buttonFrame)
}
contentHeight += buttonSize.height
if environment.safeInsets.bottom.isZero {
contentHeight += 16.0
} else {
contentHeight += environment.safeInsets.bottom + 10.0
}
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
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)
}
}
private final class StoryStealthModeSheetScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let mode: StoryStealthModeSheetScreen.Mode
let backwardDuration: Int32
let forwardDuration: Int32
let buttonAction: (() -> Void)?
init(
context: AccountContext,
mode: StoryStealthModeSheetScreen.Mode,
backwardDuration: Int32,
forwardDuration: Int32,
buttonAction: (() -> Void)?
) {
self.context = context
self.mode = mode
self.backwardDuration = backwardDuration
self.forwardDuration = forwardDuration
self.buttonAction = buttonAction
}
static func ==(lhs: StoryStealthModeSheetScreenComponent, rhs: StoryStealthModeSheetScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.backwardDuration != rhs.backwardDuration {
return false
}
if lhs.forwardDuration != rhs.forwardDuration {
return false
}
return true
}
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>()
private let sheetAnimateOut = ActionSlot<Action<Void>>()
private var component: StoryStealthModeSheetScreenComponent?
private var environment: EnvironmentType?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StoryStealthModeSheetScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.component = component
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
let sheetEnvironment = SheetComponentEnvironment(
isDisplaying: environment.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.sheetAnimateOut.invoke(Action { _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
})
}
)
let _ = self.sheet.update(
transition: transition,
component: AnyComponent(SheetComponent(
content: AnyComponent(StoryStealthModeSheetContentComponent(
mode: component.mode,
backwardDuration: component.backwardDuration,
forwardDuration: component.forwardDuration,
action: { [weak self] in
guard let self else {
return
}
self.sheetAnimateOut.invoke(Action { [weak self] _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
guard let self else {
return
}
self.component?.buttonAction?()
})
},
dismiss: {
self.sheetAnimateOut.invoke(Action { _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
})
}
)),
style: .glass,
backgroundColor: .color(environment.theme.overallDarkAppearance ? environment.theme.list.itemBlocksBackgroundColor : environment.theme.list.blocksBackgroundColor),
animateOut: self.sheetAnimateOut
)),
environment: {
environment
sheetEnvironment
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public class StoryStealthModeSheetScreen: ViewControllerComponentContainer {
public enum Mode: Equatable {
case control(external: Bool, cooldownUntilTimestamp: Int32?)
case upgrade
}
public init(
context: AccountContext,
mode: Mode,
forceDark: Bool,
backwardDuration: Int32,
forwardDuration: Int32,
buttonAction: (() -> Void)? = nil
) {
super.init(context: context, component: StoryStealthModeSheetScreenComponent(
context: context,
mode: mode,
backwardDuration: backwardDuration,
forwardDuration: forwardDuration,
buttonAction: buttonAction
), navigationBarAppearance: .none, theme: forceDark ? .dark : .default)
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.view.disablesInteractiveModalDismiss = true
}
override public func dismiss(completion: (() -> Void)? = nil) {
super.dismiss(completion: {
completion?()
})
self.wasDismissed?()
}
}