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,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() {
}
}