diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 49f3d885c0..abd301d1c9 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -842,6 +842,9 @@ public protocol SharedAccountContext: AnyObject { var hasOngoingCall: ValuePromise { get } var immediateHasOngoingCall: Bool { get } + var enablePreloads: Promise { get } + var hasPreloadBlockingContent: Promise { get } + var hasGroupCallOnScreen: Signal { get } var currentGroupCallController: ViewController? { get } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index 7169a163e8..875f0a0b05 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -909,12 +909,12 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele if self.controlsHistoryPreload, case .chatList(groupId: .root) = self.location { self.context.account.viewTracker.chatListPreloadItems.set(combineLatest(queue: .mainQueue(), - context.sharedContext.hasOngoingCall.get(), + context.sharedContext.enablePreloads.get(), itemNode.listNode.preloadItems.get(), enablePreload ) - |> map { hasOngoingCall, preloadItems, enablePreload -> Set in - if hasOngoingCall || !enablePreload { + |> map { enablePreloads, preloadItems, enablePreload -> Set in + if !enablePreloads || !enablePreload { return Set() } else { return Set(preloadItems) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 357c52e266..61a89f8c32 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -3,6 +3,10 @@ import SwiftSignalKit import Postbox import TelegramApi +public enum EngineOutgoingMessageContent { + case text(String) +} + public extension TelegramEngine { final class Messages { private let account: Account @@ -189,6 +193,31 @@ public extension TelegramEngine { public func exportMessageLink(peerId: PeerId, messageId: MessageId, isThread: Bool = false) -> Signal { return _internal_exportMessageLink(account: self.account, peerId: peerId, messageId: messageId, isThread: isThread) } + + public func enqueueOutgoingMessage( + to peerId: EnginePeer.Id, + replyTo replyToMessageId: EngineMessage.Id?, + content: EngineOutgoingMessageContent + ) { + switch content { + case let .text(text): + let message: EnqueueMessage = .message( + text: text, + attributes: [], + inlineStickers: [:], + mediaReference: nil, + replyToMessageId: replyToMessageId, + localGroupingKey: nil, + correlationId: nil, + bubbleUpEmojiOrStickersets: [] + ) + let _ = enqueueMessages( + account: self.account, + peerId: peerId, + messages: [message] + ).start() + } + } public func enqueueOutgoingMessageWithChatContextResult(to peerId: PeerId, threadId: Int64?, botId: PeerId, result: ChatContextResult, replyToMessageId: MessageId? = nil, hideVia: Bool = false, silentPosting: Bool = false, scheduleTime: Int32? = nil, correlationId: Int64? = nil) -> Bool { return _internal_enqueueOutgoingMessageWithChatContextResult(account: self.account, to: peerId, threadId: threadId, botId: botId, result: result, replyToMessageId: replyToMessageId, hideVia: hideVia, silentPosting: silentPosting, scheduleTime: scheduleTime, correlationId: correlationId) @@ -197,6 +226,19 @@ public extension TelegramEngine { public func outgoingMessageWithChatContextResult(to peerId: PeerId, threadId: Int64?, botId: PeerId, result: ChatContextResult, replyToMessageId: MessageId?, hideVia: Bool, silentPosting: Bool, scheduleTime: Int32?, correlationId: Int64?) -> EnqueueMessage? { return _internal_outgoingMessageWithChatContextResult(to: peerId, threadId: threadId, botId: botId, result: result, replyToMessageId: replyToMessageId, hideVia: hideVia, silentPosting: silentPosting, scheduleTime: scheduleTime, correlationId: correlationId) } + + public func setMessageReactions( + id: EngineMessage.Id, + reactions: [UpdateMessageReaction] + ) { + let _ = updateMessageReactionsInteractively( + account: self.account, + messageId: id, + reactions: reactions, + isLarge: false, + storeAsRecentlyUsed: false + ).start() + } public func requestChatContextResults(botId: PeerId, peerId: PeerId, query: String, location: Signal<(Double, Double)?, NoError> = .single(nil), offset: String, incompleteResults: Bool = false, staleCachedResults: Bool = false) -> Signal { return _internal_requestChatContextResults(account: self.account, botId: botId, peerId: peerId, query: query, location: location, offset: offset, incompleteResults: incompleteResults, staleCachedResults: staleCachedResults) diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index 8f6a87d262..e9921659df 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -347,12 +347,16 @@ public extension Message { var hasReactions: Bool { for attribute in self.attributes { if let attribute = attribute as? ReactionsMessageAttribute { - return !attribute.reactions.isEmpty + if !attribute.reactions.isEmpty { + return true + } } } for attribute in self.attributes { if let attribute = attribute as? PendingReactionsMessageAttribute { - return !attribute.reactions.isEmpty + if !attribute.reactions.isEmpty { + return true + } } } return false diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD index 1f614ccdb6..c7f54f14a7 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD @@ -13,6 +13,7 @@ swift_library( "//submodules/Display", "//submodules/ComponentFlow", "//submodules/AppBundle", + "//submodules/TelegramUI/Components/TextFieldComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift new file mode 100644 index 0000000000..933a84d00e --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift @@ -0,0 +1,167 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AppBundle + +public final class MessageInputActionButtonComponent: Component { + public enum Mode { + case send + case voiceInput + case videoInput + } + + public let mode: Mode + public let action: () -> Void + + public init( + mode: Mode, + action: @escaping () -> Void + ) { + self.mode = mode + self.action = action + } + + public static func ==(lhs: MessageInputActionButtonComponent, rhs: MessageInputActionButtonComponent) -> Bool { + if lhs.mode != rhs.mode { + return false + } + return true + } + + public final class View: HighlightTrackingButton { + private let microphoneIconView: UIImageView + private let cameraIconView: UIImageView + private let sendIconView: UIImageView + + private var component: MessageInputActionButtonComponent? + private weak var componentState: EmptyComponentState? + + override init(frame: CGRect) { + self.microphoneIconView = UIImageView() + + self.cameraIconView = UIImageView() + self.sendIconView = UIImageView() + + super.init(frame: frame) + + self.isMultipleTouchEnabled = false + + self.addSubview(self.microphoneIconView) + self.addSubview(self.cameraIconView) + self.addSubview(self.sendIconView) + + self.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + + let scale: CGFloat = highlighted ? 0.6 : 1.0 + + let transition = Transition(animation: .curve(duration: highlighted ? 0.5 : 0.3, curve: .spring)) + transition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(scale, scale, 1.0)) + } + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + self.component?.action() + } + + override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + return super.continueTracking(touch, with: event) + } + + func update(component: MessageInputActionButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.componentState = state + + if self.microphoneIconView.image == nil { + self.microphoneIconView.image = UIImage(bundleImageName: "Chat/Input/Text/IconMicrophone")?.withRenderingMode(.alwaysTemplate) + self.microphoneIconView.tintColor = .white + } + if self.cameraIconView.image == nil { + self.cameraIconView.image = UIImage(bundleImageName: "Chat/Input/Text/IconVideo")?.withRenderingMode(.alwaysTemplate) + self.cameraIconView.tintColor = .white + } + + if self.sendIconView.image == nil { + self.sendIconView.image = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.setBlendMode(.copy) + context.setStrokeColor(UIColor.clear.cgColor) + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setLineJoin(.round) + + context.translateBy(x: 5.45, y: 4.0) + + context.saveGState() + context.translateBy(x: 4.0, y: 4.0) + let _ = try? drawSvgPath(context, path: "M1,7 L7,1 L13,7 S ") + context.restoreGState() + + context.saveGState() + context.translateBy(x: 10.0, y: 4.0) + let _ = try? drawSvgPath(context, path: "M1,16 V1 S ") + context.restoreGState() + }) + } + + var sendAlpha: CGFloat = 0.0 + var microphoneAlpha: CGFloat = 0.0 + var cameraAlpha: CGFloat = 0.0 + + switch component.mode { + case .send: + sendAlpha = 1.0 + case .videoInput: + cameraAlpha = 1.0 + case .voiceInput: + microphoneAlpha = 1.0 + } + + transition.setAlpha(view: self.sendIconView, alpha: sendAlpha) + transition.setScale(view: self.sendIconView, scale: sendAlpha == 0.0 ? 0.01 : 1.0) + + transition.setAlpha(view: self.cameraIconView, alpha: cameraAlpha) + transition.setScale(view: self.cameraIconView, scale: cameraAlpha == 0.0 ? 0.01 : 1.0) + + transition.setAlpha(view: self.microphoneIconView, alpha: microphoneAlpha) + transition.setScale(view: self.microphoneIconView, scale: microphoneAlpha == 0.0 ? 0.01 : 1.0) + + if let image = self.sendIconView.image { + let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - image.size.width) * 0.5), y: floorToScreenPixels((availableSize.height - image.size.height) * 0.5)), size: image.size) + transition.setPosition(view: self.sendIconView, position: iconFrame.center) + transition.setBounds(view: self.sendIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + } + if let image = self.cameraIconView.image { + let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - image.size.width) * 0.5), y: floorToScreenPixels((availableSize.height - image.size.height) * 0.5)), size: image.size) + transition.setPosition(view: self.cameraIconView, position: iconFrame.center) + transition.setBounds(view: self.cameraIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + } + if let image = self.microphoneIconView.image { + let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - image.size.width) * 0.5), y: floorToScreenPixels((availableSize.height - image.size.height) * 0.5)), size: image.size) + transition.setPosition(view: self.microphoneIconView, position: iconFrame.center) + transition.setBounds(view: self.microphoneIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.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/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 9a24a16301..93d9542efb 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -3,31 +3,56 @@ import UIKit import Display import ComponentFlow import AppBundle +import TextFieldComponent public final class MessageInputPanelComponent: Component { - public init() { + public final class ExternalState { + public fileprivate(set) var hasText: Bool = false + public init() { + } + } + + public let externalState: ExternalState + public let sendMessageAction: () -> Void + + public init( + externalState: ExternalState, + sendMessageAction: @escaping () -> Void + ) { + self.externalState = externalState + self.sendMessageAction = sendMessageAction } public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool { + if lhs.externalState !== rhs.externalState { + return false + } return true } + public enum SendMessageInput { + case text(String) + } + public final class View: UIView { private let fieldBackgroundView: UIImageView - private let fieldPlaceholder = ComponentView() + + private let textField = ComponentView() + private let textFieldExternalState = TextFieldComponent.ExternalState() private let attachmentIconView: UIImageView - private let recordingIconView: UIImageView + private let inputActionButton = ComponentView() private let stickerIconView: UIImageView + private var currentMediaInputIsVoice: Bool = true + 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) @@ -36,7 +61,6 @@ public final class MessageInputPanelComponent: Component { self.addSubview(self.fieldBackgroundView) self.addSubview(self.attachmentIconView) - self.addSubview(self.recordingIconView) self.addSubview(self.stickerIconView) } @@ -44,7 +68,22 @@ public final class MessageInputPanelComponent: Component { fatalError("init(coder:) has not been implemented") } + public func getSendMessageInput() -> SendMessageInput { + guard let textFieldView = self.textField.view as? TextFieldComponent.View else { + return .text("") + } + + return .text(textFieldView.getText()) + } + + public func clearSendMessageInput() { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.setText(string: "") + } + } + func update(component: MessageInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let baseHeight: CGFloat = 44.0 let insets = UIEdgeInsets(top: 5.0, left: 41.0, bottom: 5.0, right: 41.0) let fieldCornerRadius: CGFloat = 16.0 @@ -58,47 +97,82 @@ public final class MessageInputPanelComponent: Component { 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)) + let availableTextFieldSize = CGSize(width: availableSize.width - insets.left - insets.right, height: availableSize.height - insets.top - insets.bottom) + + self.textField.parentState = state + let textFieldSize = self.textField.update( + transition: .immediate, + component: AnyComponent(TextFieldComponent( + externalState: self.textFieldExternalState, + placeholder: "Reply Privately..." + )), + environment: {}, + containerSize: availableTextFieldSize + ) + + let fieldFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: availableSize.width - insets.left - insets.right, height: textFieldSize.height)) 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) + let size = CGSize(width: availableSize.width, height: textFieldSize.height + insets.top + insets.bottom) + + if let textFieldView = self.textField.view { + if textFieldView.superview == nil { + self.addSubview(textFieldView) } - 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))) + transition.setFrame(view: textFieldView, frame: CGRect(origin: CGPoint(x: fieldFrame.minX, y: fieldFrame.maxY - textFieldSize.height), size: textFieldSize)) } 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)) + transition.setFrame(view: self.attachmentIconView, frame: CGRect(origin: CGPoint(x: floor((insets.left - image.size.width) * 0.5), y: size.height - baseHeight + floor((baseHeight - image.size.height) * 0.5)), size: image.size)) } - return availableSize + let inputActionButtonSize = self.inputActionButton.update( + transition: transition, + component: AnyComponent(MessageInputActionButtonComponent( + mode: self.textFieldExternalState.hasText ? .send : (self.currentMediaInputIsVoice ? .voiceInput : .videoInput), + action: { [weak self] in + guard let self else { + return + } + + if case .text("") = self.getSendMessageInput() { + self.currentMediaInputIsVoice = !self.currentMediaInputIsVoice + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + + HapticFeedback().impact() + } else { + self.component?.sendMessageAction() + } + } + )), + environment: {}, + containerSize: CGSize(width: 33.0, height: 33.0) + ) + if let inputActionButtonView = self.inputActionButton.view { + if inputActionButtonView.superview == nil { + self.addSubview(inputActionButtonView) + } + transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: size.width - insets.right + floorToScreenPixels((insets.right - inputActionButtonSize.width) * 0.5), y: size.height - baseHeight + floorToScreenPixels((baseHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) + } + if let image = self.stickerIconView.image { + let stickerIconFrame = 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) + transition.setPosition(view: self.stickerIconView, position: stickerIconFrame.center) + transition.setBounds(view: self.stickerIconView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) + + transition.setAlpha(view: self.stickerIconView, alpha: self.textFieldExternalState.hasText ? 0.0 : 1.0) + transition.setScale(view: self.stickerIconView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) + } + + component.externalState.hasText = self.textFieldExternalState.hasText + + return size } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 7c3d66be69..039f18159f 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -13,10 +13,14 @@ swift_library( "//submodules/Display", "//submodules/ComponentFlow", "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", "//submodules/TelegramUI/Components/MessageInputPanelComponent", "//submodules/AccountContext", "//submodules/SSignalKit/SwiftSignalKit", "//submodules/AppBundle", + "//submodules/TelegramCore", + "//submodules/ShareController", + "//submodules/UndoUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryActionsComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryActionsComponent.swift new file mode 100644 index 0000000000..af11956e7c --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryActionsComponent.swift @@ -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 = Transition(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: Transition) { + 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, transition: Transition) -> 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, 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 index eb08a95dc0..e5e3f1d83a 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -7,19 +7,40 @@ import AccountContext import SwiftSignalKit import AppBundle import MessageInputPanelComponent +import ShareController +import TelegramCore +import UndoUI + +private func hasFirstResponder(_ view: UIView) -> Bool { + if view.isFirstResponder { + return true + } + for subview in view.subviews { + if hasFirstResponder(subview) { + return true + } + } + return false +} private final class StoryContainerScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment + let context: AccountContext let initialContent: StoryContentItemSlice init( + context: AccountContext, initialContent: StoryContentItemSlice ) { + self.context = context self.initialContent = initialContent } static func ==(lhs: StoryContainerScreenComponent, rhs: StoryContainerScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } if lhs.initialContent !== rhs.initialContent { return false } @@ -65,16 +86,20 @@ private final class StoryContainerScreenComponent: Component { private let contentContainerView: UIView private let topContentGradientLayer: SimpleGradientLayer + private let bottomContentGradientLayer: SimpleGradientLayer + private let contentDimLayer: SimpleLayer private let closeButton: HighlightableButton private let closeButtonIconView: UIImageView private let navigationStrip = ComponentView() + private let inlineActions = ComponentView() private var centerInfoItem: InfoItem? private var rightInfoItem: InfoItem? private let inputPanel = ComponentView() + private let inputPanelExternalState = MessageInputPanelComponent.ExternalState() private var component: StoryContainerScreenComponent? private weak var state: EmptyComponentState? @@ -89,6 +114,8 @@ private final class StoryContainerScreenComponent: Component { private var visibleItems: [AnyHashable: VisibleItem] = [:] + private var preloadContexts: [AnyHashable: Disposable] = [:] + override init(frame: CGRect) { self.scrollView = ScrollView() @@ -97,6 +124,8 @@ private final class StoryContainerScreenComponent: Component { self.contentContainerView.isUserInteractionEnabled = false self.topContentGradientLayer = SimpleGradientLayer() + self.bottomContentGradientLayer = SimpleGradientLayer() + self.contentDimLayer = SimpleLayer() self.closeButton = HighlightableButton() self.closeButtonIconView = UIImageView() @@ -123,7 +152,9 @@ private final class StoryContainerScreenComponent: Component { self.scrollView.clipsToBounds = true self.addSubview(self.contentContainerView) + self.layer.addSublayer(self.contentDimLayer) self.layer.addSublayer(self.topContentGradientLayer) + self.layer.addSublayer(self.bottomContentGradientLayer) self.closeButton.addSubview(self.closeButtonIconView) self.addSubview(self.closeButton) @@ -142,32 +173,36 @@ private final class StoryContainerScreenComponent: Component { @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 + if hasFirstResponder(self) { + self.endEditing(true) } 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) + let point = recognizer.location(in: self) - self.currentSliceDisposable?.dispose() - self.currentSliceDisposable = (currentSlice.update( - currentSlice, - focusedItemId - ) - |> deliverOnMainQueue).start(next: { [weak self] contentSlice in - guard let self else { - return - } - self.currentSlice = contentSlice + 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) + }) + } } } } @@ -256,6 +291,123 @@ private final class StoryContainerScreenComponent: Component { }) } + private func performSendMessageAction() { + guard let component = self.component else { + return + } + guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let targetMessageId = focusedItem.targetMessageId else { + return + } + guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { + return + } + + switch inputPanelView.getSendMessageInput() { + case let .text(text): + if !text.isEmpty { + component.context.engine.messages.enqueueOutgoingMessage( + to: targetMessageId.peerId, + replyTo: targetMessageId, + content: .text(text) + ) + inputPanelView.clearSendMessageInput() + + if let controller = self.environment?.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } + } + } + } + + private func performInlineAction(item: StoryActionsComponent.Item) { + guard let component = self.component else { + return + } + guard let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) else { + return + } + guard let targetMessageId = focusedItem.targetMessageId else { + return + } + + switch item.kind { + case .like: + if item.isActivated { + component.context.engine.messages.setMessageReactions( + id: targetMessageId, + reactions: [ + ] + ) + } else { + component.context.engine.messages.setMessageReactions( + id: targetMessageId, + reactions: [ + .builtin("❤") + ] + ) + } + case .share: + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Messages.Message(id: targetMessageId) + ) + |> deliverOnMainQueue).start(next: { [weak self] message in + guard let self, let message, let component = self.component, let controller = self.environment?.controller() else { + return + } + let shareController = ShareController( + context: component.context, + subject: .messages([message._asMessage()]), + externalShare: false, + immediateExternalShare: false, + updatedPresentationData: (component.context.sharedContext.currentPresentationData.with({ $0 }), + component.context.sharedContext.presentationData) + ) + controller.present(shareController, in: .window(.root)) + }) + } + } + + private func updatePreloads() { + var validIds: [AnyHashable] = [] + if let currentSlice = self.currentSlice, let focusedItemId = self.focusedItemId, let currentIndex = currentSlice.items.firstIndex(where: { $0.id == focusedItemId }) { + for i in 0 ..< 2 { + var nextIndex: Int = currentIndex - 1 - i + nextIndex = max(0, min(nextIndex, currentSlice.items.count - 1)) + if nextIndex != currentIndex { + let nextItem = currentSlice.items[nextIndex] + + validIds.append(nextItem.id) + if self.preloadContexts[nextItem.id] == nil { + if let signal = nextItem.preload { + self.preloadContexts[nextItem.id] = signal.start() + } + } + } + } + } + + var removeIds: [AnyHashable] = [] + for (id, disposable) in self.preloadContexts { + if !validIds.contains(id) { + removeIds.append(id) + disposable.dispose() + } + } + for id in removeIds { + self.preloadContexts.removeValue(forKey: id) + } + } + func update(component: StoryContainerScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let isFirstTime = self.component == nil @@ -298,6 +450,27 @@ private final class StoryContainerScreenComponent: Component { self.topContentGradientLayer.colors = colors self.topContentGradientLayer.type = .axial } + if self.bottomContentGradientLayer.colors == nil { + var locations: [NSNumber] = [] + var colors: [CGColor] = [] + let numStops = 10 + let baseAlpha: CGFloat = 0.7 + 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.bottomContentGradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0) + self.bottomContentGradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0) + + self.bottomContentGradientLayer.locations = locations + self.bottomContentGradientLayer.colors = colors + self.bottomContentGradientLayer.type = .axial + + self.contentDimLayer.backgroundColor = UIColor(white: 0.0, alpha: 0.3).cgColor + } if let focusedItemId = self.focusedItemId { if let currentSlice = self.currentSlice { @@ -309,15 +482,47 @@ private final class StoryContainerScreenComponent: Component { } } + self.updatePreloads() + self.component = component self.state = state self.environment = environment - let bottomContentInset: CGFloat + var bottomContentInset: CGFloat if !environment.safeInsets.bottom.isZero { - bottomContentInset = environment.safeInsets.bottom + 5.0 + 44.0 + bottomContentInset = environment.safeInsets.bottom + 5.0 } else { - bottomContentInset = 44.0 + bottomContentInset = 0.0 + } + + self.inputPanel.parentState = state + let inputPanelSize = self.inputPanel.update( + transition: transition, + component: AnyComponent(MessageInputPanelComponent( + externalState: self.inputPanelExternalState, + sendMessageAction: { [weak self] in + guard let self else { + return + } + self.performSendMessageAction() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 200.0) + ) + + let bottomContentInsetWithoutInput = bottomContentInset + + let inputPanelBottomInset: CGFloat + let inputPanelIsOverlay: Bool + if environment.inputHeight < bottomContentInset + inputPanelSize.height { + inputPanelBottomInset = bottomContentInset + bottomContentInset += inputPanelSize.height + inputPanelIsOverlay = false + } else { + bottomContentInset += 44.0 + inputPanelBottomInset = environment.inputHeight + inputPanelIsOverlay = true } let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: environment.statusBarHeight), size: CGSize(width: availableSize.width, height: availableSize.height - environment.statusBarHeight - bottomContentInset)) @@ -348,7 +553,7 @@ private final class StoryContainerScreenComponent: Component { 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.animateScale(from: 1.0, to: 0.5, 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() }) @@ -395,7 +600,7 @@ private final class StoryContainerScreenComponent: Component { 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) + view.layer.animateScale(from: 0.5, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } } } @@ -445,27 +650,76 @@ private final class StoryContainerScreenComponent: Component { } 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))) } + + if let focusedItemId = self.focusedItemId, let focusedItem = self.currentSlice?.items.first(where: { $0.id == focusedItemId }) { + let inlineActionsSize = self.inlineActions.update( + transition: transition, + component: AnyComponent(StoryActionsComponent( + items: [ + StoryActionsComponent.Item( + kind: .like, + isActivated: focusedItem.hasLike + ), + StoryActionsComponent.Item( + kind: .share, + isActivated: false + ) + ], + action: { [weak self] item in + guard let self else { + return + } + self.performInlineAction(item: item) + } + )), + environment: {}, + containerSize: contentFrame.size + ) + if let inlineActionsView = self.inlineActions.view { + if inlineActionsView.superview == nil { + self.addSubview(inlineActionsView) + } + transition.setFrame(view: inlineActionsView, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 10.0 - inlineActionsSize.width, y: contentFrame.maxY - 20.0 - inlineActionsSize.height), size: inlineActionsSize)) + transition.setAlpha(view: inlineActionsView, alpha: inputPanelIsOverlay ? 0.0 : 1.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) + let itemLayout = ItemLayout(size: CGSize(width: contentFrame.width, height: availableSize.height - environment.statusBarHeight - 44.0 - bottomContentInsetWithoutInput)) self.itemLayout = itemLayout - let inputPanelSize = self.inputPanel.update( - transition: transition, - component: AnyComponent(MessageInputPanelComponent( - )), - environment: {}, - containerSize: CGSize(width: availableSize.width, height: 44.0) - ) + let inputPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputPanelBottomInset - inputPanelSize.height), size: inputPanelSize) 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)) + transition.setFrame(view: inputPanelView, frame: inputPanelFrame) } + let bottomGradientHeight = inputPanelSize.height + 32.0 + transition.setFrame(layer: self.bottomContentGradientLayer, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: availableSize.height - environment.inputHeight - bottomGradientHeight), size: CGSize(width: contentFrame.width, height: bottomGradientHeight))) + transition.setAlpha(layer: self.bottomContentGradientLayer, alpha: inputPanelIsOverlay ? 1.0 : 0.0) + + if let controller = environment.controller() { + let subLayout = ContainerViewLayout( + size: availableSize, + metrics: environment.metrics, + deviceMetrics: environment.deviceMetrics, + intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: availableSize.height - min(inputPanelFrame.minY, contentFrame.maxY), right: 0.0), + safeInsets: UIEdgeInsets(), + additionalInsets: UIEdgeInsets(), + statusBarHeight: nil, + inputHeight: nil, + inputHeightIsInteractivellyChanging: false, + inVoiceOver: false + ) + controller.presentationContext.containerLayoutUpdated(subLayout, transition: transition.containedViewLayoutTransition) + } + + transition.setFrame(layer: self.contentDimLayer, frame: contentFrame) + transition.setAlpha(layer: self.contentDimLayer, alpha: inputPanelIsOverlay ? 1.0 : 0.0) 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))) @@ -490,20 +744,26 @@ private final class StoryContainerScreenComponent: Component { } public class StoryContainerScreen: ViewControllerComponentContainer { + private let context: AccountContext private var isDismissed: Bool = false public init( context: AccountContext, initialContent: StoryContentItemSlice ) { + self.context = context + super.init(context: context, component: StoryContainerScreenComponent( + context: context, initialContent: initialContent ), navigationBarAppearance: .none) self.statusBar.statusBarStyle = .White self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true - //self.automaticallyControlPresentationContextLayout = false + self.automaticallyControlPresentationContextLayout = false + + self.context.sharedContext.hasPreloadBlockingContent.set(.single(true)) } required public init(coder aDecoder: NSCoder) { @@ -511,6 +771,7 @@ public class StoryContainerScreen: ViewControllerComponentContainer { } deinit { + self.context.sharedContext.hasPreloadBlockingContent.set(.single(false)) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -531,7 +792,11 @@ public class StoryContainerScreen: ViewControllerComponentContainer { if !self.isDismissed { self.isDismissed = true + self.statusBar.updateStatusBarStyle(.Ignore, animated: true) + if let componentView = self.node.hostView.componentView as? StoryContainerScreenComponent.View { + componentView.endEditing(true) + componentView.animateOut(completion: { [weak self] in completion?() self?.dismiss(animated: false) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift index c5756dc148..194a52d30b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContent.swift @@ -3,6 +3,7 @@ import UIKit import Display import ComponentFlow import SwiftSignalKit +import TelegramCore public final class StoryContentItem { public let id: AnyHashable @@ -10,19 +11,28 @@ public final class StoryContentItem { public let component: AnyComponent public let centerInfoComponent: AnyComponent? public let rightInfoComponent: AnyComponent? + public let targetMessageId: EngineMessage.Id? + public let preload: Signal? + public let hasLike: Bool public init( id: AnyHashable, position: Int, component: AnyComponent, centerInfoComponent: AnyComponent?, - rightInfoComponent: AnyComponent? + rightInfoComponent: AnyComponent?, + targetMessageId: EngineMessage.Id?, + preload: Signal?, + hasLike: Bool ) { self.id = id self.position = position self.component = component self.centerInfoComponent = centerInfoComponent self.rightInfoComponent = rightInfoComponent + self.targetMessageId = targetMessageId + self.preload = preload + self.hasLike = hasLike } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index 598f9926cd..59f6050388 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -30,6 +30,22 @@ public enum StoryChatContent { if let location = entry.location { totalCount = location.count } + + var hasLike = false + if let reactions = entry.message.effectiveReactions { + for reaction in reactions { + if !reaction.isSelected { + continue + } + if reaction.value == .builtin("❤") { + hasLike = true + } + } + } + + var preload: Signal? + preload = StoryMessageContentComponent.preload(context: context, message: EngineMessage(entry.message)) + items.append(StoryContentItem( id: AnyHashable(entry.message.id), position: entry.location?.index ?? 0, @@ -46,7 +62,10 @@ public enum StoryChatContent { context: context, peer: EnginePeer(author) )) - } + }, + targetMessageId: entry.message.id, + preload: preload, + hasLike: hasLike )) } return StoryContentItemSlice( diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift index 173773ca5a..6c7ba42183 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryMessageContentComponent.swift @@ -28,6 +28,56 @@ final class StoryMessageContentComponent: Component { } return true } + + static func preload(context: AccountContext, message: EngineMessage) -> Signal { + var messageMedia: EngineMedia? + for media in message.media { + switch media { + case let image as TelegramMediaImage: + messageMedia = .image(image) + case let file as TelegramMediaFile: + messageMedia = .file(file) + default: + break + } + } + + guard let messageMedia else { + return .complete() + } + + var fetchSignal: Signal? + switch messageMedia { + case let .image(image): + if let representation = image.representations.last { + fetchSignal = fetchedMediaResource( + mediaBox: context.account.postbox.mediaBox, + userLocation: .peer(message.id.peerId), + userContentType: .image, + reference: ImageMediaReference.message(message: MessageReference(message._asMessage()), media: image).resourceReference(representation.resource) + ) + |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + } + } + case let .file(file): + fetchSignal = fetchedMediaResource( + mediaBox: context.account.postbox.mediaBox, + userLocation: .peer(message.id.peerId), + userContentType: .image, + reference: FileMediaReference.message(message: MessageReference(message._asMessage()), media: file).resourceReference(file.resource) + ) + |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + } + default: + break + } + + return fetchSignal ?? .complete() + } final class View: UIView { private let imageNode: TransformImageNode @@ -85,6 +135,7 @@ final class StoryMessageContentComponent: Component { return } if value { + self.videoNode?.seek(0.0) self.videoNode?.play() } } @@ -133,8 +184,16 @@ final class StoryMessageContentComponent: Component { 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) + fetchSignal = fetchedMediaResource( + mediaBox: component.context.account.postbox.mediaBox, + userLocation: .peer(component.message.id.peerId), + userContentType: .image, + reference: ImageMediaReference.message(message: MessageReference(component.message._asMessage()), media: image).resourceReference(representation.resource) + ) |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + } } case let .file(file): signal = chatMessageVideo( @@ -143,8 +202,16 @@ final class StoryMessageContentComponent: Component { 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) + fetchSignal = fetchedMediaResource( + mediaBox: component.context.account.postbox.mediaBox, + userLocation: .peer(component.message.id.peerId), + userContentType: .image, + reference: FileMediaReference.message(message: MessageReference(component.message._asMessage()), media: file).resourceReference(file.resource) + ) |> ignoreValues + |> `catch` { _ -> Signal in + return .complete() + } default: break } diff --git a/submodules/TelegramUI/Components/TextFieldComponent/BUILD b/submodules/TelegramUI/Components/TextFieldComponent/BUILD new file mode 100644 index 0000000000..b63a8be5ba --- /dev/null +++ b/submodules/TelegramUI/Components/TextFieldComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "TextFieldComponent", + module_name = "TextFieldComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift new file mode 100644 index 0000000000..8887912ac0 --- /dev/null +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -0,0 +1,170 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class TextFieldComponent: Component { + public final class ExternalState { + public fileprivate(set) var hasText: Bool = false + + public init() { + } + } + + public final class AnimationHint { + public enum Kind { + case textChanged + } + + public let kind: Kind + + fileprivate init(kind: Kind) { + self.kind = kind + } + } + + public let externalState: ExternalState + public let placeholder: String + + public init( + externalState: ExternalState, + placeholder: String + ) { + self.externalState = externalState + self.placeholder = placeholder + } + + public static func ==(lhs: TextFieldComponent, rhs: TextFieldComponent) -> Bool { + if lhs.externalState !== rhs.externalState { + return false + } + if lhs.placeholder != rhs.placeholder { + return false + } + return true + } + + public final class View: UIView, UITextViewDelegate, UIScrollViewDelegate { + private let placeholder = ComponentView() + + private let textContainer: NSTextContainer + private let textStorage: NSTextStorage + private let layoutManager: NSLayoutManager + private let textView: UITextView + + private var component: TextFieldComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.textContainer = NSTextContainer(size: CGSize()) + self.textContainer.widthTracksTextView = false + self.textContainer.heightTracksTextView = false + self.textContainer.lineBreakMode = .byWordWrapping + self.textContainer.lineFragmentPadding = 8.0 + + self.textStorage = NSTextStorage() + + self.layoutManager = NSLayoutManager() + self.layoutManager.allowsNonContiguousLayout = false + self.layoutManager.addTextContainer(self.textContainer) + self.textStorage.addLayoutManager(self.layoutManager) + + self.textView = UITextView(frame: CGRect(), textContainer: self.textContainer) + self.textView.translatesAutoresizingMaskIntoConstraints = false + self.textView.textContainerInset = UIEdgeInsets(top: 6.0, left: 8.0, bottom: 7.0, right: 8.0) + self.textView.backgroundColor = nil + self.textView.layer.isOpaque = false + self.textView.keyboardAppearance = .dark + self.textView.indicatorStyle = .white + self.textView.scrollIndicatorInsets = UIEdgeInsets(top: 9.0, left: 0.0, bottom: 9.0, right: 0.0) + + super.init(frame: frame) + + self.clipsToBounds = true + + self.textView.delegate = self + self.addSubview(self.textView) + + self.textContainer.widthTracksTextView = false + self.textContainer.heightTracksTextView = false + + self.textView.typingAttributes = [ + NSAttributedString.Key.font: Font.regular(17.0), + NSAttributedString.Key.foregroundColor: UIColor.white + ] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func textViewDidChange(_ textView: UITextView) { + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + //print("didScroll \(scrollView.bounds)") + } + + public func getText() -> String { + Keyboard.applyAutocorrection(textView: self.textView) + return self.textView.text ?? "" + } + + public func setText(string: String) { + self.textView.text = string + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } + + func update(component: TextFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + self.textContainer.size = CGSize(width: availableSize.width - self.textView.textContainerInset.left - self.textView.textContainerInset.right, height: 10000000.0) + self.layoutManager.ensureLayout(for: self.textContainer) + + let boundingRect = self.layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: self.textStorage.length), in: self.textContainer) + let size = CGSize(width: availableSize.width, height: min(100.0, ceil(boundingRect.height) + self.textView.textContainerInset.top + self.textView.textContainerInset.bottom)) + + let refreshScrolling = self.textView.bounds.size != size + self.textView.frame = CGRect(origin: CGPoint(), size: size) + //transition.setFrame(view: self.textView, frame: ) + + if refreshScrolling { + self.textView.setContentOffset(CGPoint(x: 0.0, y: max(0.0, self.textView.contentSize.height - self.textView.bounds.height)), animated: false) + } + + let placeholderSize = self.placeholder.update( + transition: .immediate, + component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: UIColor(white: 1.0, alpha: 0.25))), + environment: {}, + containerSize: availableSize + ) + if let placeholderView = self.placeholder.view { + if placeholderView.superview == nil { + placeholderView.layer.anchorPoint = CGPoint() + placeholderView.isUserInteractionEnabled = false + self.insertSubview(placeholderView, belowSubview: self.textView) + } + + let placeholderFrame = CGRect(origin: CGPoint(x: self.textView.textContainerInset.left + 5.0, y: self.textView.textContainerInset.top), size: placeholderSize) + placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) + transition.setPosition(view: placeholderView, position: placeholderFrame.origin) + + placeholderView.isHidden = self.textStorage.length != 0 + } + + component.externalState.hasText = self.textStorage.length != 0 + + return size + } + } + + 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/Images.xcassets/Media Gallery/InlineLike.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineLike.imageset/Contents.json new file mode 100644 index 0000000000..02d32abd47 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineLike.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "GalleryShare.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineLike.imageset/GalleryShare.svg b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineLike.imageset/GalleryShare.svg new file mode 100644 index 0000000000..6912911acf --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineLike.imageset/GalleryShare.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/Contents.json new file mode 100644 index 0000000000..1eb5fb05ef --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "GalleryForward.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/GalleryForward.svg b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/GalleryForward.svg new file mode 100644 index 0000000000..d2a5c9f43a --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Media Gallery/InlineShare.imageset/GalleryForward.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 3ea5d9910b..aebb08f135 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -130,6 +130,9 @@ public final class SharedAccountContextImpl: SharedAccountContext { } private var hasOngoingCallDisposable: Disposable? + public let enablePreloads = Promise() + public let hasPreloadBlockingContent = Promise(false) + private var accountUserInterfaceInUseContexts: [AccountRecordId: AccountUserInterfaceInUseContext] = [:] var switchingData: (settingsController: (SettingsController & ViewController)?, chatListController: ChatListController?, chatListBadge: String?) = (nil, nil, nil) @@ -869,6 +872,20 @@ public final class SharedAccountContextImpl: SharedAccountContext { let _ = immediateHasOngoingCallValue.swap(value) }) + self.enablePreloads.set(combineLatest( + self.hasOngoingCall.get(), + self.hasPreloadBlockingContent.get() + ) + |> map { hasOngoingCall, hasPreloadBlockingContent -> Bool in + if hasOngoingCall { + return false + } + if hasPreloadBlockingContent { + return false + } + return true + }) + let _ = managedCleanupAccounts(networkArguments: networkArguments, accountManager: self.accountManager, rootPath: rootPath, auxiliaryMethods: makeTelegramAccountAuxiliaryMethods(appDelegate: appDelegate), encryptionParameters: encryptionParameters).start() self.updateNotificationTokensRegistration()