diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index 9aa30f9383..2620357f47 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -92,7 +92,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case inlineForums(Bool) case localTranscription(Bool) case enableReactionOverrides(Bool) - case playerEmbedding(Bool) + case storiesExperiment(Bool) case playlistPlayback(Bool) case enableQuickReactionSwitch(Bool) case voiceConference @@ -118,7 +118,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.logging.rawValue case .keepChatNavigationStack, .skipReadHistory, .crashOnSlowQueries: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .resetData, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .resetWebViewCache, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .playerEmbedding, .playlistPlayback, .enableQuickReactionSwitch, .voiceConference, .experimentalCompatibility, .enableDebugDataDisplay, .acceleratedStickers, .inlineForums, .localTranscription, .enableReactionOverrides, .restorePurchases: + case .clearTips, .resetNotifications, .crash, .resetData, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .resetWebViewCache, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .playlistPlayback, .enableQuickReactionSwitch, .voiceConference, .experimentalCompatibility, .enableDebugDataDisplay, .acceleratedStickers, .inlineForums, .localTranscription, .enableReactionOverrides, .restorePurchases: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -213,7 +213,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 41 case .resetTranslationStates: return 42 - case .playerEmbedding: + case .storiesExperiment: return 43 case .playlistPlayback: return 44 @@ -1220,12 +1220,12 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) - case let .playerEmbedding(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Player Embedding", value: value, sectionId: self.section, style: .blocks, updated: { value in + case let .storiesExperiment(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Gallery X", value: value, sectionId: self.section, style: .blocks, updated: { value in let _ = arguments.sharedContext.accountManager.transaction ({ transaction in transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings - settings.playerEmbedding = value + settings.storiesExperiment = value return PreferencesEntry(settings) }) }).start() @@ -1384,7 +1384,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present entries.append(.logTranslationRecognition(experimentalSettings.logLanguageRecognition)) entries.append(.resetTranslationStates) - entries.append(.playerEmbedding(experimentalSettings.playerEmbedding)) + entries.append(.storiesExperiment(experimentalSettings.storiesExperiment)) entries.append(.playlistPlayback(experimentalSettings.playlistPlayback)) entries.append(.enableQuickReactionSwitch(!experimentalSettings.disableQuickReaction)) } diff --git a/submodules/GalleryData/BUILD b/submodules/GalleryData/BUILD index 5e3be4d4e2..6c8d7399cc 100644 --- a/submodules/GalleryData/BUILD +++ b/submodules/GalleryData/BUILD @@ -24,6 +24,8 @@ swift_library( "//submodules/PeerAvatarGalleryUI:PeerAvatarGalleryUI", "//submodules/MediaResources:MediaResources", "//submodules/WebsiteType:WebsiteType", + "//submodules/TelegramUI/Components/Stories/StoryContainerScreen", + "//submodules/TelegramUI/Components/Stories/StoryContentComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index 931fecacc6..10acbaaa3e 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -14,6 +14,8 @@ import PeerAvatarGalleryUI import GalleryUI import MediaResources import WebsiteType +import StoryContainerScreen +import StoryContentComponent public enum ChatMessageGalleryControllerData { case url(String) @@ -28,6 +30,7 @@ public enum ChatMessageGalleryControllerData { case chatAvatars(AvatarGalleryController, Media) case theme(TelegramMediaFile) case other(Media) + case story(Signal) } private func instantPageBlockMedia(pageId: MediaId, block: InstantPageBlock, media: [MediaId: Media], counter: inout Int) -> [InstantPageGalleryEntry] { @@ -267,6 +270,21 @@ public func chatMessageGalleryControllerData(context: AccountContext, chatLocati openChatLocationContextHolder = Atomic(value: nil) } + if context.sharedContext.immediateExperimentalUISettings.storiesExperiment { + return .story(StoryChatContent.messages( + context: context, + messageId: message.id + ) + |> take(1) + |> deliverOnMainQueue + |> map { initialContent in + return StoryContainerScreen( + context: context, + initialContent: initialContent + ) + }) + } + return .gallery(startState |> deliverOnMainQueue |> map { startState in diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index c830fe9deb..2bc7c2294d 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -2057,8 +2057,8 @@ public func chatWebpageSnippetPhoto(account: Account, userLocation: MediaResourc } } -public func chatMessageVideo(postbox: Postbox, userLocation: MediaResourceUserLocation, videoReference: FileMediaReference) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { - return mediaGridMessageVideo(postbox: postbox, userLocation: userLocation, videoReference: videoReference) +public func chatMessageVideo(postbox: Postbox, userLocation: MediaResourceUserLocation, videoReference: FileMediaReference, synchronousLoad: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + return mediaGridMessageVideo(postbox: postbox, userLocation: userLocation, videoReference: videoReference, synchronousLoad: synchronousLoad) } private func chatSecretMessageVideoData(account: Account, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, synchronousLoad: Bool) -> Signal { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 2125a4eca0..aedf979642 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -360,6 +360,8 @@ swift_library( "//submodules/TelegramUI/Components/SendInviteLinkScreen", "//submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen", "//submodules/TelegramUI/Components/SliderContextItem:SliderContextItem", + "//submodules/TelegramUI/Components/Stories/StoryContainerScreen", + "//submodules/TelegramUI/Components/Stories/StoryContentComponent", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD new file mode 100644 index 0000000000..1f614ccdb6 --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD @@ -0,0 +1,20 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MessageInputPanelComponent", + module_name = "MessageInputPanelComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift new file mode 100644 index 0000000000..9a24a16301 --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -0,0 +1,112 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AppBundle + +public final class MessageInputPanelComponent: Component { + public init() { + + } + + public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool { + return true + } + + public final class View: UIView { + private let fieldBackgroundView: UIImageView + private let fieldPlaceholder = ComponentView() + + private let attachmentIconView: UIImageView + private let recordingIconView: UIImageView + private let stickerIconView: UIImageView + + private var component: MessageInputPanelComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.fieldBackgroundView = UIImageView() + self.attachmentIconView = UIImageView() + self.recordingIconView = UIImageView() + self.stickerIconView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.fieldBackgroundView) + + self.addSubview(self.fieldBackgroundView) + self.addSubview(self.attachmentIconView) + self.addSubview(self.recordingIconView) + self.addSubview(self.stickerIconView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: MessageInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let insets = UIEdgeInsets(top: 5.0, left: 41.0, bottom: 5.0, right: 41.0) + let fieldCornerRadius: CGFloat = 16.0 + + self.component = component + self.state = state + + if self.fieldBackgroundView.image == nil { + self.fieldBackgroundView.image = generateStretchableFilledCircleImage(diameter: fieldCornerRadius * 2.0, color: nil, strokeColor: UIColor(white: 1.0, alpha: 0.16), strokeWidth: 1.0, backgroundColor: nil) + } + if self.attachmentIconView.image == nil { + self.attachmentIconView.image = UIImage(bundleImageName: "Chat/Input/Text/IconAttachment")?.withRenderingMode(.alwaysTemplate) + self.attachmentIconView.tintColor = .white + } + if self.recordingIconView.image == nil { + self.recordingIconView.image = UIImage(bundleImageName: "Chat/Input/Text/IconMicrophone")?.withRenderingMode(.alwaysTemplate) + self.recordingIconView.tintColor = .white + } + if self.stickerIconView.image == nil { + self.stickerIconView.image = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconStickers")?.withRenderingMode(.alwaysTemplate) + self.stickerIconView.tintColor = .white + } + + let fieldFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: availableSize.width - insets.left - insets.right, height: availableSize.height - insets.top - insets.bottom)) + transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame) + + let rightFieldInset: CGFloat = 34.0 + + let placeholderSize = self.fieldPlaceholder.update( + transition: .immediate, + component: AnyComponent(Text(text: "Reply Privately...", font: Font.regular(17.0), color: UIColor(white: 1.0, alpha: 0.16))), + environment: {}, + containerSize: fieldFrame.size + ) + if let fieldPlaceholderView = self.fieldPlaceholder.view { + if fieldPlaceholderView.superview == nil { + fieldPlaceholderView.layer.anchorPoint = CGPoint() + fieldPlaceholderView.isUserInteractionEnabled = false + self.addSubview(fieldPlaceholderView) + } + fieldPlaceholderView.bounds = CGRect(origin: CGPoint(), size: placeholderSize) + transition.setPosition(view: fieldPlaceholderView, position: CGPoint(x: fieldFrame.minX + 12.0, y: fieldFrame.minY + floor((fieldFrame.height - placeholderSize.height) * 0.5))) + } + + if let image = self.attachmentIconView.image { + transition.setFrame(view: self.attachmentIconView, frame: CGRect(origin: CGPoint(x: floor((insets.left - image.size.width) * 0.5), y: floor((availableSize.height - image.size.height) * 0.5)), size: image.size)) + } + if let image = self.recordingIconView.image { + transition.setFrame(view: self.recordingIconView, frame: CGRect(origin: CGPoint(x: availableSize.width - insets.right + floor((insets.right - image.size.width) * 0.5), y: floor((availableSize.height - image.size.height) * 0.5)), size: image.size)) + } + if let image = self.stickerIconView.image { + transition.setFrame(view: self.stickerIconView, frame: CGRect(origin: CGPoint(x: fieldFrame.maxX - rightFieldInset + floor((rightFieldInset - image.size.width) * 0.5), y: fieldFrame.minY + floor((fieldFrame.height - image.size.height) * 0.5)), size: image.size)) + } + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD new file mode 100644 index 0000000000..7c3d66be69 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -0,0 +1,24 @@ +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/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/TelegramUI/Components/MessageInputPanelComponent", + "//submodules/AccountContext", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/MediaNavigationStripComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/MediaNavigationStripComponent.swift new file mode 100644 index 0000000000..8de025765b --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/MediaNavigationStripComponent.swift @@ -0,0 +1,139 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +final class MediaNavigationStripComponent: Component { + let index: Int + let count: Int + + init(index: Int, count: Int) { + self.index = index + self.count = count + } + + static func ==(lhs: MediaNavigationStripComponent, rhs: MediaNavigationStripComponent) -> Bool { + if lhs.index != rhs.index { + return false + } + if lhs.count != rhs.count { + return false + } + return true + } + + private final class ItemLayer: SimpleLayer { + override init() { + super.init() + + self.cornerRadius = 1.5 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(layer: Any) { + super.init(layer: layer) + } + } + + final class View: UIView { + private var visibleItems: [Int: ItemLayer] = [:] + + 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 update(component: MediaNavigationStripComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let spacing: CGFloat = 3.0 + let itemHeight: CGFloat = 2.0 + let minItemWidth: CGFloat = 3.0 + + var validIndices: [Int] = [] + if component.count != 0 { + var idealItemWidth: CGFloat = (availableSize.width - CGFloat(component.count - 1) * spacing) / CGFloat(component.count) + idealItemWidth = round(idealItemWidth) + + let itemWidth: CGFloat + if idealItemWidth < minItemWidth { + itemWidth = minItemWidth + } else { + itemWidth = idealItemWidth + } + + let globalWidth: CGFloat = CGFloat(component.count) * itemWidth + CGFloat(component.count - 1) * spacing + 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 + } + + //itemWidth * itemCount + (itemCount - 1) * spacing = width + //itemWidth * itemCount + itemCount * spacing - spacing = width + //itemCount * (itemWidth + spacing) = width + spacing + //itemCount = (width + spacing) / (itemWidth + spacing) + let potentiallyVisibleCount = Int(ceil((availableSize.width + spacing) / (itemWidth + spacing))) + for i in (component.index - potentiallyVisibleCount) ... (component.index + potentiallyVisibleCount) { + if i < 0 { + continue + } + if i >= component.count { + continue + } + let itemFrame = CGRect(origin: CGPoint(x: -globalOffset + CGFloat(i) * (itemWidth + spacing), y: 0.0), size: CGSize(width: itemWidth, height: itemHeight)) + if itemFrame.maxY < 0.0 || itemFrame.minY >= 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 + itemLayer.cornerRadius = itemHeight * 0.5 + } + + transition.setFrame(layer: itemLayer, frame: itemFrame) + + itemLayer.backgroundColor = UIColor(white: 1.0, alpha: i == component.index ? 1.0 : 0.5).cgColor + } + } + + 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, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift new file mode 100644 index 0000000000..eb08a95dc0 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -0,0 +1,544 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ViewControllerComponent +import AccountContext +import SwiftSignalKit +import AppBundle +import MessageInputPanelComponent + +private final class StoryContainerScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let initialContent: StoryContentItemSlice + + init( + initialContent: StoryContentItemSlice + ) { + self.initialContent = initialContent + } + + static func ==(lhs: StoryContainerScreenComponent, rhs: StoryContainerScreenComponent) -> Bool { + if lhs.initialContent !== rhs.initialContent { + return false + } + return true + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private struct ItemLayout { + var size: CGSize + + init(size: CGSize) { + self.size = size + } + } + + private final class VisibleItem { + let view = ComponentView() + + init() { + } + } + + private final class InfoItem { + let component: AnyComponent + let view = ComponentView() + + init(component: AnyComponent) { + self.component = component + } + } + + final class View: UIView, UIScrollViewDelegate { + private let scrollView: ScrollView + + private let contentContainerView: UIView + private let topContentGradientLayer: SimpleGradientLayer + + private let closeButton: HighlightableButton + private let closeButtonIconView: UIImageView + + private let navigationStrip = ComponentView() + + private var centerInfoItem: InfoItem? + private var rightInfoItem: InfoItem? + + private let inputPanel = ComponentView() + + private var component: StoryContainerScreenComponent? + private weak var state: EmptyComponentState? + private var environment: ViewControllerComponentContainer.Environment? + + private var itemLayout: ItemLayout? + private var ignoreScrolling: Bool = false + + private var focusedItemId: AnyHashable? + private var currentSlice: StoryContentItemSlice? + private var currentSliceDisposable: Disposable? + + private var visibleItems: [AnyHashable: VisibleItem] = [:] + + override init(frame: CGRect) { + self.scrollView = ScrollView() + + self.contentContainerView = UIView() + self.contentContainerView.clipsToBounds = true + self.contentContainerView.isUserInteractionEnabled = false + + self.topContentGradientLayer = SimpleGradientLayer() + + self.closeButton = HighlightableButton() + self.closeButtonIconView = UIImageView() + + super.init(frame: frame) + + self.backgroundColor = .black + + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.addSubview(self.contentContainerView) + self.layer.addSublayer(self.topContentGradientLayer) + + self.closeButton.addSubview(self.closeButtonIconView) + self.addSubview(self.closeButton) + self.closeButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside) + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.currentSliceDisposable?.dispose() + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state, let currentSlice = self.currentSlice, let focusedItemId = self.focusedItemId, let currentIndex = currentSlice.items.firstIndex(where: { $0.id == focusedItemId }), let itemLayout = self.itemLayout { + let point = recognizer.location(in: self) + + var nextIndex: Int + if point.x < itemLayout.size.width * 0.5 { + nextIndex = currentIndex + 1 + } else { + nextIndex = currentIndex - 1 + } + nextIndex = max(0, min(nextIndex, currentSlice.items.count - 1)) + if nextIndex != currentIndex { + let focusedItemId = currentSlice.items[nextIndex].id + self.focusedItemId = focusedItemId + self.state?.updated(transition: .immediate) + + self.currentSliceDisposable?.dispose() + self.currentSliceDisposable = (currentSlice.update( + currentSlice, + focusedItemId + ) + |> deliverOnMainQueue).start(next: { [weak self] contentSlice in + guard let self else { + return + } + self.currentSlice = contentSlice + self.state?.updated(transition: .immediate) + }) + } + } + } + + @objc private func closePressed() { + guard let environment = self.environment, let controller = environment.controller() else { + return + } + controller.dismiss() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + private func updateScrolling(transition: Transition) { + guard let itemLayout = self.itemLayout else { + return + } + + var validIds: [AnyHashable] = [] + if let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) { + validIds.append(focusedItemId) + + var itemTransition = transition + let visibleItem: VisibleItem + if let current = self.visibleItems[focusedItemId] { + visibleItem = current + } else { + itemTransition = .immediate + visibleItem = VisibleItem() + self.visibleItems[focusedItemId] = visibleItem + } + + let _ = visibleItem.view.update( + transition: itemTransition, + component: focusedItem.component, + environment: {}, + containerSize: itemLayout.size + ) + if let view = visibleItem.view.view { + if view.superview == nil { + self.contentContainerView.addSubview(view) + } + itemTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: itemLayout.size)) + } + } + + var removeIds: [AnyHashable] = [] + for (id, visibleItem) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + if let view = visibleItem.view.view { + view.removeFromSuperview() + } + } + } + for id in removeIds { + self.visibleItems.removeValue(forKey: id) + } + } + + func animateIn() { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in + guard let self else { + return + } + self.layer.allowsGroupOpacity = false + }) + } + + func animateOut(completion: @escaping () -> Void) { + self.layer.allowsGroupOpacity = true + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + completion() + }) + } + + func update(component: StoryContainerScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let isFirstTime = self.component == nil + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + + if self.component == nil { + self.focusedItemId = component.initialContent.focusedItemId + self.currentSlice = component.initialContent + + self.currentSliceDisposable?.dispose() + self.currentSliceDisposable = (component.initialContent.update( + component.initialContent, + component.initialContent.focusedItemId + ) + |> deliverOnMainQueue).start(next: { [weak self] contentSlice in + guard let self else { + return + } + self.currentSlice = contentSlice + self.state?.updated(transition: .immediate) + }) + } + + if self.topContentGradientLayer.colors == nil { + var locations: [NSNumber] = [] + var colors: [CGColor] = [] + let numStops = 4 + let baseAlpha: CGFloat = 0.5 + for i in 0 ..< numStops { + let step = 1.0 - CGFloat(i) / CGFloat(numStops - 1) + locations.append((1.0 - step) as NSNumber) + let alphaStep: CGFloat = pow(step, 1.5) + colors.append(UIColor.black.withAlphaComponent(alphaStep * baseAlpha).cgColor) + } + + self.topContentGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + self.topContentGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.topContentGradientLayer.locations = locations + self.topContentGradientLayer.colors = colors + self.topContentGradientLayer.type = .axial + } + + if let focusedItemId = self.focusedItemId { + if let currentSlice = self.currentSlice { + if !currentSlice.items.contains(where: { $0.id == focusedItemId }) { + self.focusedItemId = currentSlice.items.first?.id + } + } else { + self.focusedItemId = nil + } + } + + self.component = component + self.state = state + self.environment = environment + + let bottomContentInset: CGFloat + if !environment.safeInsets.bottom.isZero { + bottomContentInset = environment.safeInsets.bottom + 5.0 + 44.0 + } else { + bottomContentInset = 44.0 + } + + let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: environment.statusBarHeight), size: CGSize(width: availableSize.width, height: availableSize.height - environment.statusBarHeight - bottomContentInset)) + transition.setFrame(view: self.contentContainerView, frame: contentFrame) + transition.setCornerRadius(layer: self.contentContainerView.layer, cornerRadius: 14.0) + + if self.closeButtonIconView.image == nil { + self.closeButtonIconView.image = UIImage(bundleImageName: "Media Gallery/Close")?.withRenderingMode(.alwaysTemplate) + self.closeButtonIconView.tintColor = .white + } + if let image = self.closeButtonIconView.image { + let closeButtonFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: 50.0, height: 64.0)) + transition.setFrame(view: self.closeButton, frame: closeButtonFrame) + transition.setFrame(view: self.closeButtonIconView, frame: CGRect(origin: CGPoint(x: floor((closeButtonFrame.width - image.size.width) * 0.5), y: floor((closeButtonFrame.height - image.size.height) * 0.5)), size: image.size)) + } + + var currentRightInfoItem: InfoItem? + if let currentSlice = self.currentSlice, let item = currentSlice.items.first(where: { $0.id == self.focusedItemId }) { + if let rightInfoComponent = item.rightInfoComponent { + if let rightInfoItem = self.rightInfoItem, rightInfoItem.component == item.rightInfoComponent { + currentRightInfoItem = rightInfoItem + } else { + currentRightInfoItem = InfoItem(component: rightInfoComponent) + } + } + } + + if let rightInfoItem = self.rightInfoItem, currentRightInfoItem?.component != rightInfoItem.component { + self.rightInfoItem = nil + if let view = rightInfoItem.view.view { + view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } + } + + var currentCenterInfoItem: InfoItem? + if let currentSlice = self.currentSlice, let item = currentSlice.items.first(where: { $0.id == self.focusedItemId }) { + if let centerInfoComponent = item.centerInfoComponent { + if let centerInfoItem = self.centerInfoItem, centerInfoItem.component == item.centerInfoComponent { + currentCenterInfoItem = centerInfoItem + } else { + currentCenterInfoItem = InfoItem(component: centerInfoComponent) + } + } + } + + if let centerInfoItem = self.centerInfoItem, currentCenterInfoItem?.component != centerInfoItem.component { + self.centerInfoItem = nil + if let view = centerInfoItem.view.view { + view.removeFromSuperview() + /*view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak view] _ in + view?.removeFromSuperview() + })*/ + } + } + + if let currentRightInfoItem { + self.rightInfoItem = currentRightInfoItem + + let rightInfoItemSize = currentRightInfoItem.view.update( + transition: .immediate, + component: currentRightInfoItem.component, + environment: {}, + containerSize: CGSize(width: 36.0, height: 36.0) + ) + if let view = currentRightInfoItem.view.view { + var animateIn = false + if view.superview == nil { + self.addSubview(view) + animateIn = true + } + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 6.0 - rightInfoItemSize.width, y: contentFrame.minY + 14.0), size: rightInfoItemSize)) + + if animateIn, !isFirstTime { + view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + } + + if let currentCenterInfoItem { + self.centerInfoItem = currentCenterInfoItem + + let centerInfoItemSize = currentCenterInfoItem.view.update( + transition: .immediate, + component: currentCenterInfoItem.component, + environment: {}, + containerSize: CGSize(width: contentFrame.width, height: 44.0) + ) + if let view = currentCenterInfoItem.view.view { + var animateIn = false + if view.superview == nil { + view.isUserInteractionEnabled = false + self.addSubview(view) + animateIn = true + } + transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY + 10.0), size: centerInfoItemSize)) + + if animateIn, !isFirstTime { + //view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + if let currentSlice = self.currentSlice { + let navigationStripSideInset: CGFloat = 8.0 + let navigationStripTopInset: CGFloat = 8.0 + + let index = currentSlice.items.first(where: { $0.id == self.focusedItemId })?.position ?? 0 + + let _ = self.navigationStrip.update( + transition: transition, + component: AnyComponent(MediaNavigationStripComponent( + index: max(0, min(currentSlice.totalCount - 1 - index, currentSlice.totalCount - 1)), + count: currentSlice.totalCount + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - navigationStripSideInset * 2.0, height: 2.0) + ) + if let navigationStripView = self.navigationStrip.view { + if navigationStripView.superview == nil { + self.addSubview(navigationStripView) + } + transition.setFrame(view: navigationStripView, frame: CGRect(origin: CGPoint(x: contentFrame.minX + navigationStripSideInset, y: contentFrame.minY + navigationStripTopInset), size: CGSize(width: availableSize.width - navigationStripSideInset * 2.0, height: 2.0))) + } + } + + let gradientHeight: CGFloat = 74.0 + transition.setFrame(layer: self.topContentGradientLayer, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: gradientHeight))) + + let itemLayout = ItemLayout(size: contentFrame.size) + self.itemLayout = itemLayout + + let inputPanelSize = self.inputPanel.update( + transition: transition, + component: AnyComponent(MessageInputPanelComponent( + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 44.0) + ) + if let inputPanelView = self.inputPanel.view { + if inputPanelView.superview == nil { + self.addSubview(inputPanelView) + } + transition.setFrame(view: inputPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentFrame.maxY), size: inputPanelSize)) + } + + self.ignoreScrolling = true + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) + let contentSize = availableSize + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class StoryContainerScreen: ViewControllerComponentContainer { + private var isDismissed: Bool = false + + public init( + context: AccountContext, + initialContent: StoryContentItemSlice + ) { + super.init(context: context, component: StoryContainerScreenComponent( + initialContent: initialContent + ), navigationBarAppearance: .none) + + self.statusBar.statusBarStyle = .White + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + //self.automaticallyControlPresentationContextLayout = false + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.disablesInteractiveModalDismiss = true + + if let componentView = self.node.hostView.componentView as? StoryContainerScreenComponent.View { + componentView.animateIn() + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + + if let componentView = self.node.hostView.componentView as? StoryContainerScreenComponent.View { + componentView.animateOut(completion: { [weak self] in + completion?() + self?.dismiss(animated: false) + }) + } else { + self.dismiss(animated: false) + } + } + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift new file mode 100644 index 0000000000..c5756dc148 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -0,0 +1,46 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import SwiftSignalKit + +public final class StoryContentItem { + public let id: AnyHashable + public let position: Int + public let component: AnyComponent + public let centerInfoComponent: AnyComponent? + public let rightInfoComponent: AnyComponent? + + public init( + id: AnyHashable, + position: Int, + component: AnyComponent, + centerInfoComponent: AnyComponent?, + rightInfoComponent: AnyComponent? + ) { + self.id = id + self.position = position + self.component = component + self.centerInfoComponent = centerInfoComponent + self.rightInfoComponent = rightInfoComponent + } +} + +public final class StoryContentItemSlice { + public let focusedItemId: AnyHashable + public let items: [StoryContentItem] + public let totalCount: Int + public let update: (StoryContentItemSlice, AnyHashable) -> Signal + + public init( + focusedItemId: AnyHashable, + items: [StoryContentItem], + totalCount: Int, + update: @escaping (StoryContentItemSlice, AnyHashable) -> Signal + ) { + self.focusedItemId = focusedItemId + self.items = items + self.totalCount = totalCount + self.update = update + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/BUILD b/submodules/TelegramUI/Components/Stories/StoryContentComponent/BUILD new file mode 100644 index 0000000000..5b7d77123e --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/BUILD @@ -0,0 +1,28 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StoryContentComponent", + module_name = "StoryContentComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/AsyncDisplayKit", + "//submodules/ComponentFlow", + "//submodules/TelegramUI/Components/Stories/StoryContainerScreen", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/TelegramCore", + "//submodules/PhotoResources", + "//submodules/MediaPlayer:UniversalMediaPlayer", + "//submodules/TelegramUniversalVideoContent", + "//submodules/AvatarNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAuthorInfoComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAuthorInfoComponent.swift new file mode 100644 index 0000000000..914b7f2375 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAuthorInfoComponent.swift @@ -0,0 +1,96 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AccountContext +import TelegramCore +import TelegramStringFormatting + +final class StoryAuthorInfoComponent: Component { + let context: AccountContext + let message: EngineMessage + + init(context: AccountContext, message: EngineMessage) { + self.context = context + self.message = message + } + + static func ==(lhs: StoryAuthorInfoComponent, rhs: StoryAuthorInfoComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.message != rhs.message { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + private let subtitle = ComponentView() + + private var component: StoryAuthorInfoComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: StoryAuthorInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let size = availableSize + let spacing: CGFloat = 0.0 + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + let title = component.message.author?.debugDisplayTitle ?? "" + let subtitle = humanReadableStringForTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, timestamp: component.message.timestamp).string + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(Text(text: title, font: Font.semibold(17.0), color: .white)), + environment: {}, + containerSize: availableSize + ) + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(Text(text: subtitle, font: Font.regular(12.0), color: UIColor(white: 1.0, alpha: 0.8))), + environment: {}, + containerSize: availableSize + ) + + let contentHeight: CGFloat = titleSize.height + spacing + subtitleSize.height + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((availableSize.height - contentHeight) * 0.5)), size: titleSize) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: titleFrame.maxY + spacing), size: subtitleSize) + + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.addSubview(subtitleView) + } + transition.setFrame(view: subtitleView, frame: subtitleFrame) + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift new file mode 100644 index 0000000000..fd75f34511 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryAvatarInfoComponent.swift @@ -0,0 +1,71 @@ +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 + + init(context: AccountContext, peer: EnginePeer) { + self.context = context + self.peer = peer + } + + static func ==(lhs: StoryAvatarInfoComponent, rhs: StoryAvatarInfoComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peer != rhs.peer { + 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: 15.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, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let size = CGSize(width: 36.0, height: 36.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 + ) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift new file mode 100644 index 0000000000..598f9926cd --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -0,0 +1,72 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import StoryContainerScreen + +public enum StoryChatContent { + public static func messages( + context: AccountContext, + messageId: EngineMessage.Id + ) -> Signal { + return context.account.postbox.aroundIdMessageHistoryViewForLocation( + .peer(peerId: messageId.peerId, threadId: nil), + ignoreMessagesInTimestampRange: nil, + count: 10, + messageId: messageId, + topTaggedMessageIdNamespaces: Set(), + tagMask: .photoOrVideo, + appendMessagesFromTheSameGroup: false, + namespaces: .not(Set([Namespaces.Message.ScheduledCloud, Namespaces.Message.ScheduledLocal])), + orderStatistics: .combinedLocation + ) + |> map { view -> StoryContentItemSlice in + var items: [StoryContentItem] = [] + var totalCount = 0 + for entry in view.0.entries { + if let location = entry.location { + totalCount = location.count + } + items.append(StoryContentItem( + id: AnyHashable(entry.message.id), + position: entry.location?.index ?? 0, + component: AnyComponent(StoryMessageContentComponent( + context: context, + message: EngineMessage(entry.message) + )), + centerInfoComponent: AnyComponent(StoryAuthorInfoComponent( + context: context, + message: EngineMessage(entry.message) + )), + rightInfoComponent: entry.message.author.flatMap { author -> AnyComponent in + return AnyComponent(StoryAvatarInfoComponent( + context: context, + peer: EnginePeer(author) + )) + } + )) + } + return StoryContentItemSlice( + focusedItemId: AnyHashable(messageId), + items: items, + totalCount: totalCount, + update: { _, itemId in + if let id = itemId.base as? EngineMessage.Id { + return StoryChatContent.messages( + context: context, + messageId: id + ) + } else { + return StoryChatContent.messages( + context: context, + messageId: messageId + ) + } + } + ) + } + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift new file mode 100644 index 0000000000..173773ca5a --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift @@ -0,0 +1,213 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AccountContext +import TelegramCore +import AsyncDisplayKit +import PhotoResources +import SwiftSignalKit +import UniversalMediaPlayer +import TelegramUniversalVideoContent + +final class StoryMessageContentComponent: Component { + let context: AccountContext + let message: EngineMessage + + init(context: AccountContext, message: EngineMessage) { + self.context = context + self.message = message + } + + static func ==(lhs: StoryMessageContentComponent, rhs: StoryMessageContentComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.message != rhs.message { + return false + } + return true + } + + final class View: UIView { + private let imageNode: TransformImageNode + private var videoNode: UniversalVideoNode? + + private var currentMessageMedia: EngineMedia? + private var fetchDisposable: Disposable? + + private var component: StoryMessageContentComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.imageNode = TransformImageNode() + + super.init(frame: frame) + + self.addSubnode(self.imageNode) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.fetchDisposable?.dispose() + } + + private func performActionAfterImageContentLoaded(update: Bool) { + guard let component = self.component, let currentMessageMedia = self.currentMessageMedia else { + return + } + + if case let .file(file) = currentMessageMedia { + if self.videoNode == nil { + let videoNode = UniversalVideoNode( + postbox: component.context.account.postbox, + audioSession: component.context.sharedContext.mediaManager.audioSession, + manager: component.context.sharedContext.mediaManager.universalVideoManager, + decoration: StoryVideoDecoration(), + content: NativeVideoContent( + id: .message(component.message.stableId, file.fileId), + userLocation: .peer(component.message.id.peerId), + fileReference: .message(message: MessageReference(component.message._asMessage()), media: file), + imageReference: nil, + loopVideo: true, + enableSound: true, + tempFilePath: nil, + captureProtected: component.message._asMessage().isCopyProtected(), + storeAfterDownload: nil + ), + priority: .gallery + ) + videoNode.ownsContentNodeUpdated = { [weak self] value in + guard let self else { + return + } + if value { + self.videoNode?.play() + } + } + videoNode.canAttachContent = true + self.videoNode = videoNode + self.addSubnode(videoNode) + if update { + self.state?.updated(transition: .immediate) + } + } + } + } + + func update(component: StoryMessageContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + var messageMedia: EngineMedia? + for media in component.message.media { + switch media { + case let image as TelegramMediaImage: + messageMedia = .image(image) + case let file as TelegramMediaFile: + messageMedia = .file(file) + default: + break + } + } + + var reloadMedia = false + if self.currentMessageMedia?.id != messageMedia?.id { + self.currentMessageMedia = messageMedia + reloadMedia = true + } + + if reloadMedia, let messageMedia { + var signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + var fetchSignal: Signal? + switch messageMedia { + case let .image(image): + signal = chatMessagePhoto( + postbox: component.context.account.postbox, + userLocation: .peer(component.message.id.peerId), + photoReference: .message(message: MessageReference(component.message._asMessage()), media: image), + synchronousLoad: true, + highQuality: true + ) + if let representation = image.representations.last { + fetchSignal = messageMediaImageInteractiveFetched(context: component.context, message: component.message._asMessage(), image: image, resource: representation.resource, userInitiated: true, storeToDownloadsPeerId: component.message.id.peerId) + |> ignoreValues + } + case let .file(file): + signal = chatMessageVideo( + postbox: component.context.account.postbox, + userLocation: .peer(component.message.id.peerId), + videoReference: .message(message: MessageReference(component.message._asMessage()), media: file), + synchronousLoad: true + ) + fetchSignal = messageMediaFileInteractiveFetched(context: component.context, message: component.message._asMessage(), file: file, userInitiated: true, storeToDownloadsPeerId: component.message.id.peerId) + |> ignoreValues + default: + break + } + + if let signal { + var wasSynchronous = true + self.imageNode.setSignal(signal |> afterCompleted { [weak self] in + Queue.mainQueue().async { + guard let self else { + return + } + + self.performActionAfterImageContentLoaded(update: !wasSynchronous) + } + }, attemptSynchronously: true) + wasSynchronous = false + } + + self.fetchDisposable?.dispose() + self.fetchDisposable = nil + if let fetchSignal { + self.fetchDisposable = fetchSignal.start() + } + } + + if let messageMedia { + var dimensions: CGSize? + switch messageMedia { + case let .image(image): + dimensions = image.representations.last?.dimensions.cgSize + case let .file(file): + dimensions = file.dimensions?.cgSize + default: + break + } + + if let dimensions { + let apply = self.imageNode.asyncLayout()(TransformImageArguments( + corners: ImageCorners(), + imageSize: dimensions.aspectFilled(availableSize), + boundingSize: availableSize, + intrinsicInsets: UIEdgeInsets() + )) + apply() + + if let videoNode = self.videoNode { + let videoSize = dimensions.aspectFilled(availableSize) + videoNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) * 0.5), y: floor((availableSize.height - videoSize.height) * 0.5)), size: videoSize) + videoNode.updateLayout(size: videoSize, transition: .immediate) + } + } + self.imageNode.frame = CGRect(origin: CGPoint(), size: availableSize) + } + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryVideoDecoration.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryVideoDecoration.swift new file mode 100644 index 0000000000..bed9c3d450 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryVideoDecoration.swift @@ -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 validLayoutSize: 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 validLayoutSize = self.validLayoutSize { + contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize) + contentNode.updateLayout(size: validLayoutSize, 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, transition: ContainedViewLayoutTransition) { + self.validLayoutSize = size + + 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, transition: transition) + } + } + + public func setStatus(_ status: Signal) { + } + + public func tap() { + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/Contents.json new file mode 100644 index 0000000000..52ca28db8c --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/icon.svg b/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/icon.svg new file mode 100644 index 0000000000..163f631c23 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/Close.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index f87528e5a0..77c96c872e 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -22,6 +22,7 @@ import ShareController import UndoUI import WebsiteType import GalleryData +import StoryContainerScreen func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { if let mediaData = chatMessageGalleryControllerData(context: params.context, chatLocation: params.chatLocation, chatLocationContextHolder: params.chatLocationContextHolder, message: params.message, navigationController: params.navigationController, standalone: params.standalone, reverseMessageGalleryOrder: params.reverseMessageGalleryOrder, mode: params.mode, source: params.gallerySource, synchronousLoad: false, actionInteraction: params.actionInteraction) { @@ -172,6 +173,12 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { } params.context.sharedContext.mediaManager.setPlaylist((params.context.account, PeerMessagesMediaPlaylist(context: params.context, location: location, chatLocationContextHolder: params.chatLocationContextHolder)), type: playerType, control: control) return true + case let .story(storyController): + params.dismissInput() + let _ = (storyController + |> deliverOnMainQueue).start(next: { storyController in + params.navigationController?.pushViewController(storyController) + }) case let .gallery(gallery): params.dismissInput() let _ = (gallery diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index ae4ede014c..54a2987973 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -50,6 +50,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var disableImageContentAnalysis: Bool public var disableBackgroundAnimation: Bool public var logLanguageRecognition: Bool + public var storiesExperiment: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -77,7 +78,8 @@ public struct ExperimentalUISettings: Codable, Equatable { disableLanguageRecognition: false, disableImageContentAnalysis: false, disableBackgroundAnimation: false, - logLanguageRecognition: false + logLanguageRecognition: false, + storiesExperiment: false ) } @@ -106,7 +108,8 @@ public struct ExperimentalUISettings: Codable, Equatable { disableLanguageRecognition: Bool, disableImageContentAnalysis: Bool, disableBackgroundAnimation: Bool, - logLanguageRecognition: Bool + logLanguageRecognition: Bool, + storiesExperiment: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -133,6 +136,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableImageContentAnalysis = disableImageContentAnalysis self.disableBackgroundAnimation = disableBackgroundAnimation self.logLanguageRecognition = logLanguageRecognition + self.storiesExperiment = storiesExperiment } public init(from decoder: Decoder) throws { @@ -163,6 +167,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.disableImageContentAnalysis = try container.decodeIfPresent(Bool.self, forKey: "disableImageContentAnalysis") ?? false self.disableBackgroundAnimation = try container.decodeIfPresent(Bool.self, forKey: "disableBackgroundAnimation") ?? false self.logLanguageRecognition = try container.decodeIfPresent(Bool.self, forKey: "logLanguageRecognition") ?? false + self.storiesExperiment = try container.decodeIfPresent(Bool.self, forKey: "storiesExperiment") ?? false } public func encode(to encoder: Encoder) throws { @@ -193,6 +198,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encode(self.disableImageContentAnalysis, forKey: "disableImageContentAnalysis") try container.encode(self.disableBackgroundAnimation, forKey: "disableBackgroundAnimation") try container.encode(self.logLanguageRecognition, forKey: "logLanguageRecognition") + try container.encode(self.storiesExperiment, forKey: "storiesExperiment") } }