diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 602a697cde..f9485be0eb 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -836,6 +836,9 @@ public final class BotPreviewEditorTransitionOut { } } +public protocol MiniAppListScreenInitialData: AnyObject { +} + public protocol SharedAccountContext: AnyObject { var sharedContainerPath: String { get } var basePath: String { get } @@ -903,7 +906,7 @@ public protocol SharedAccountContext: AnyObject { selectedMessages: Signal?, NoError>, mode: ChatHistoryListMode ) -> ChatHistoryListNode - func makeChatMessagePreviewItem(context: AccountContext, messages: [Message], theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder, forcedResourceStatus: FileMediaResourceStatus?, tapMessage: ((Message) -> Void)?, clickThroughMessage: (() -> Void)?, backgroundNode: ASDisplayNode?, availableReactions: AvailableReactions?, accountPeer: Peer?, isCentered: Bool, isPreview: Bool, isStandalone: Bool) -> ListViewItem + func makeChatMessagePreviewItem(context: AccountContext, messages: [Message], theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder, forcedResourceStatus: FileMediaResourceStatus?, tapMessage: ((Message) -> Void)?, clickThroughMessage: ((UIView?, CGPoint?) -> Void)?, backgroundNode: ASDisplayNode?, availableReactions: AvailableReactions?, accountPeer: Peer?, isCentered: Bool, isPreview: Bool, isStandalone: Bool) -> ListViewItem func makeChatMessageDateHeaderItem(context: AccountContext, timestamp: Int32, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder) -> ListViewItemHeader func makeChatMessageAvatarHeaderItem(context: AccountContext, timestamp: Int32, peer: Peer, message: Message, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder) -> ListViewItemHeader func makePeerSharedMediaController(context: AccountContext, peerId: PeerId) -> ViewController? @@ -1006,6 +1009,10 @@ public protocol SharedAccountContext: AnyObject { func makeStarsAmountScreen(context: AccountContext, initialValue: Int64?, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsWithdrawalScreen(context: AccountContext, stats: StarsRevenueStats, completion: @escaping (Int64) -> Void) -> ViewController func makeStarsGiftScreen(context: AccountContext, message: EngineMessage) -> ViewController + + func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal + func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController + func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool) func makeDebugSettingsController(context: AccountContext?) -> ViewController? diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionController.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionController.swift index bc03da266a..5ffc394337 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionController.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionController.swift @@ -58,7 +58,7 @@ final class BotCheckoutWebInteractionController: ViewController { } override func loadDisplayNode() { - self.displayNode = BotCheckoutWebInteractionControllerNode(presentationData: self.presentationData, url: self.url, intent: self.intent) + self.displayNode = BotCheckoutWebInteractionControllerNode(context: self.context, presentationData: self.presentationData, url: self.url, intent: self.intent) } override func viewDidAppear(_ animated: Bool) { diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift index 161bd7f296..e6d52efedd 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift @@ -4,6 +4,7 @@ import Display import AsyncDisplayKit import WebKit import TelegramPresentationData +import AccountContext private class WeakPaymentScriptMessageHandler: NSObject, WKScriptMessageHandler { private let f: (WKScriptMessage) -> () @@ -20,12 +21,14 @@ private class WeakPaymentScriptMessageHandler: NSObject, WKScriptMessageHandler } final class BotCheckoutWebInteractionControllerNode: ViewControllerTracingNode, WKNavigationDelegate { + private let context: AccountContext private var presentationData: PresentationData private let intent: BotCheckoutWebInteractionControllerIntent private var webView: WKWebView? - init(presentationData: PresentationData, url: String, intent: BotCheckoutWebInteractionControllerIntent) { + init(context: AccountContext, presentationData: PresentationData, url: String, intent: BotCheckoutWebInteractionControllerIntent) { + self.context = context self.presentationData = presentationData self.intent = intent @@ -146,6 +149,14 @@ final class BotCheckoutWebInteractionControllerNode: ViewControllerTracingNode, decisionHandler(.allow) } } else { + if let url = navigationAction.request.url, let scheme = url.scheme { + let defaultSchemes: [String] = ["http", "https"] + if !defaultSchemes.contains(scheme) { + decisionHandler(.cancel) + self.context.sharedContext.applicationBindings.openUrl(url.absoluteString) + return + } + } decisionHandler(.allow) } } diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 8e17694c38..55aca75b28 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -3543,9 +3543,19 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { } else if case .apps = key { if let navigationController = self.navigationController { if isRecommended { + #if DEBUG + let _ = (self.context.sharedContext.makeMiniAppListScreenInitialData(context: self.context) + |> deliverOnMainQueue).startStandalone(next: { [weak self] initialData in + guard let self, let navigationController = self.navigationController else { + return + } + navigationController.pushViewController(self.context.sharedContext.makeMiniAppListScreen(context: self.context, initialData: initialData)) + }) + #else if let peerInfoScreen = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { navigationController.pushViewController(peerInfoScreen) } + #endif } else if case let .user(user) = peer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp), let parentController = self.parentController { self.context.sharedContext.openWebApp( context: self.context, @@ -3560,7 +3570,6 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { skipTermsOfService: true ) } else { - self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: self.context, diff --git a/submodules/Display/Source/PortalSourceView.swift b/submodules/Display/Source/PortalSourceView.swift index 337edb299f..3c932b1924 100644 --- a/submodules/Display/Source/PortalSourceView.swift +++ b/submodules/Display/Source/PortalSourceView.swift @@ -47,6 +47,13 @@ open class PortalSourceView: UIView { } } + public func removePortal(view: PortalView) { + if let index = self.portalReferences.firstIndex(where: { $0.portalView === view }) { + self.portalReferences.remove(at: index) + } + view.disablePortal() + } + func setGlobalPortal(view: GlobalPortalView?) { if let globalPortalView = self.globalPortalView { self.globalPortalView = nil diff --git a/submodules/Display/Source/PortalView.swift b/submodules/Display/Source/PortalView.swift index c26d2632fe..f5583a4cb2 100644 --- a/submodules/Display/Source/PortalView.swift +++ b/submodules/Display/Source/PortalView.swift @@ -23,6 +23,11 @@ public class PortalView { } } + func disablePortal() { + self.view.sourceView = nil + self.sourceView = nil + } + public func reloadPortal() { if let sourceView = self.sourceView as? PortalSourceView { self.reloadPortal(sourceView: sourceView) diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index 3820e7fd4d..de00d411bd 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -550,6 +550,10 @@ public final class SparseItemGrid: ASDisplayNode { var offset: CGFloat { return self.scrollView.contentOffset.y } + + var contentBottomOffset: CGFloat { + return -self.scrollView.contentOffset.y + self.scrollView.contentSize.height + } let coveringOffsetUpdated: (Viewport, ContainedViewLayoutTransition) -> Void let offsetUpdated: (Viewport, ContainedViewLayoutTransition) -> Void @@ -1442,6 +1446,10 @@ public final class SparseItemGrid: ASDisplayNode { return self.fromViewport.coveringInsetOffset * (1.0 - self.currentProgress) + self.toViewport.coveringInsetOffset * self.currentProgress } + var contentBottomOffset: CGFloat { + return self.fromViewport.contentBottomOffset * (1.0 - self.currentProgress) + self.toViewport.contentBottomOffset * self.currentProgress + } + var offset: CGFloat { return self.fromViewport.offset * (1.0 - self.currentProgress) + self.toViewport.offset * self.currentProgress } @@ -1632,6 +1640,16 @@ public final class SparseItemGrid: ASDisplayNode { } } + public var contentBottomOffset: CGFloat { + if let currentViewportTransition = self.currentViewportTransition { + return currentViewportTransition.contentBottomOffset + } else if let currentViewport = self.currentViewport { + return currentViewport.contentBottomOffset + } else { + return 0.0 + } + } + public var scrollingOffset: CGFloat { if let currentViewportTransition = self.currentViewportTransition { return currentViewportTransition.offset diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 42d3ec2a58..ff4d7a0245 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -105,6 +105,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-944407322] = { return Api.BotMenuButton.parse_botMenuButton($0) } dict[1113113093] = { return Api.BotMenuButton.parse_botMenuButtonCommands($0) } dict[1966318984] = { return Api.BotMenuButton.parse_botMenuButtonDefault($0) } + dict[602479523] = { return Api.BotPreviewMedia.parse_botPreviewMedia($0) } dict[-2076642874] = { return Api.BroadcastRevenueBalances.parse_broadcastRevenueBalances($0) } dict[1434332356] = { return Api.BroadcastRevenueTransaction.parse_broadcastRevenueTransactionProceeds($0) } dict[1121127726] = { return Api.BroadcastRevenueTransaction.parse_broadcastRevenueTransactionRefund($0) } @@ -1170,7 +1171,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-1542017919] = { return Api.auth.SentCodeType.parse_sentCodeTypeSmsWord($0) } dict[-391678544] = { return Api.bots.BotInfo.parse_botInfo($0) } dict[428978491] = { return Api.bots.PopularAppBots.parse_popularAppBots($0) } - dict[1357069389] = { return Api.bots.PreviewInfo.parse_previewInfo($0) } + dict[212278628] = { return Api.bots.PreviewInfo.parse_previewInfo($0) } dict[-309659827] = { return Api.channels.AdminLogResults.parse_adminLogResults($0) } dict[-541588713] = { return Api.channels.ChannelParticipant.parse_channelParticipant($0) } dict[-1699676497] = { return Api.channels.ChannelParticipants.parse_channelParticipants($0) } @@ -1400,7 +1401,7 @@ public extension Api { return parser(reader) } else { - telegramApiLog("Type constructor \(String(signature, radix: 16, uppercase: false)) not found") + telegramApiLog("Type constructor \(String(UInt32(bitPattern: signature), radix: 16, uppercase: false)) not found") return nil } } @@ -1494,6 +1495,8 @@ public extension Api { _1.serialize(buffer, boxed) case let _1 as Api.BotMenuButton: _1.serialize(buffer, boxed) + case let _1 as Api.BotPreviewMedia: + _1.serialize(buffer, boxed) case let _1 as Api.BroadcastRevenueBalances: _1.serialize(buffer, boxed) case let _1 as Api.BroadcastRevenueTransaction: diff --git a/submodules/TelegramApi/Sources/Api2.swift b/submodules/TelegramApi/Sources/Api2.swift index 51d1fb1d7f..8855da4143 100644 --- a/submodules/TelegramApi/Sources/Api2.swift +++ b/submodules/TelegramApi/Sources/Api2.swift @@ -724,6 +724,48 @@ public extension Api { } } +public extension Api { + indirect enum BotPreviewMedia: TypeConstructorDescription { + case botPreviewMedia(date: Int32, media: Api.MessageMedia) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .botPreviewMedia(let date, let media): + if boxed { + buffer.appendInt32(602479523) + } + serializeInt32(date, buffer: buffer, boxed: false) + media.serialize(buffer, true) + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .botPreviewMedia(let date, let media): + return ("botPreviewMedia", [("date", date as Any), ("media", media as Any)]) + } + } + + public static func parse_botPreviewMedia(_ reader: BufferReader) -> BotPreviewMedia? { + var _1: Int32? + _1 = reader.readInt32() + var _2: Api.MessageMedia? + if let signature = reader.readInt32() { + _2 = Api.parse(reader, signature: signature) as? Api.MessageMedia + } + let _c1 = _1 != nil + let _c2 = _2 != nil + if _c1 && _c2 { + return Api.BotPreviewMedia.botPreviewMedia(date: _1!, media: _2!) + } + else { + return nil + } + } + + } +} public extension Api { enum BroadcastRevenueBalances: TypeConstructorDescription { case broadcastRevenueBalances(currentBalance: Int64, availableBalance: Int64, overallRevenue: Int64) @@ -1160,53 +1202,3 @@ public extension Api { } } -public extension Api { - enum BusinessIntro: TypeConstructorDescription { - case businessIntro(flags: Int32, title: String, description: String, sticker: Api.Document?) - - public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { - switch self { - case .businessIntro(let flags, let title, let description, let sticker): - if boxed { - buffer.appendInt32(1510606445) - } - serializeInt32(flags, buffer: buffer, boxed: false) - serializeString(title, buffer: buffer, boxed: false) - serializeString(description, buffer: buffer, boxed: false) - if Int(flags) & Int(1 << 0) != 0 {sticker!.serialize(buffer, true)} - break - } - } - - public func descriptionFields() -> (String, [(String, Any)]) { - switch self { - case .businessIntro(let flags, let title, let description, let sticker): - return ("businessIntro", [("flags", flags as Any), ("title", title as Any), ("description", description as Any), ("sticker", sticker as Any)]) - } - } - - public static func parse_businessIntro(_ reader: BufferReader) -> BusinessIntro? { - var _1: Int32? - _1 = reader.readInt32() - var _2: String? - _2 = parseString(reader) - var _3: String? - _3 = parseString(reader) - var _4: Api.Document? - if Int(_1!) & Int(1 << 0) != 0 {if let signature = reader.readInt32() { - _4 = Api.parse(reader, signature: signature) as? Api.Document - } } - let _c1 = _1 != nil - let _c2 = _2 != nil - let _c3 = _3 != nil - let _c4 = (Int(_1!) & Int(1 << 0) == 0) || _4 != nil - if _c1 && _c2 && _c3 && _c4 { - return Api.BusinessIntro.businessIntro(flags: _1!, title: _2!, description: _3!, sticker: _4) - } - else { - return nil - } - } - - } -} diff --git a/submodules/TelegramApi/Sources/Api29.swift b/submodules/TelegramApi/Sources/Api29.swift index 2b03bc7c71..36dea196a1 100644 --- a/submodules/TelegramApi/Sources/Api29.swift +++ b/submodules/TelegramApi/Sources/Api29.swift @@ -1,12 +1,12 @@ public extension Api.bots { enum PreviewInfo: TypeConstructorDescription { - case previewInfo(media: [Api.MessageMedia], langCodes: [String]) + case previewInfo(media: [Api.BotPreviewMedia], langCodes: [String]) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { case .previewInfo(let media, let langCodes): if boxed { - buffer.appendInt32(1357069389) + buffer.appendInt32(212278628) } buffer.appendInt32(481674261) buffer.appendInt32(Int32(media.count)) @@ -30,9 +30,9 @@ public extension Api.bots { } public static func parse_previewInfo(_ reader: BufferReader) -> PreviewInfo? { - var _1: [Api.MessageMedia]? + var _1: [Api.BotPreviewMedia]? if let _ = reader.readInt32() { - _1 = Api.parseVector(reader, elementSignature: 0, elementType: Api.MessageMedia.self) + _1 = Api.parseVector(reader, elementSignature: 0, elementType: Api.BotPreviewMedia.self) } var _2: [String]? if let _ = reader.readInt32() { diff --git a/submodules/TelegramApi/Sources/Api3.swift b/submodules/TelegramApi/Sources/Api3.swift index 331ac6b496..3ec0b4d752 100644 --- a/submodules/TelegramApi/Sources/Api3.swift +++ b/submodules/TelegramApi/Sources/Api3.swift @@ -1,3 +1,53 @@ +public extension Api { + enum BusinessIntro: TypeConstructorDescription { + case businessIntro(flags: Int32, title: String, description: String, sticker: Api.Document?) + + public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { + switch self { + case .businessIntro(let flags, let title, let description, let sticker): + if boxed { + buffer.appendInt32(1510606445) + } + serializeInt32(flags, buffer: buffer, boxed: false) + serializeString(title, buffer: buffer, boxed: false) + serializeString(description, buffer: buffer, boxed: false) + if Int(flags) & Int(1 << 0) != 0 {sticker!.serialize(buffer, true)} + break + } + } + + public func descriptionFields() -> (String, [(String, Any)]) { + switch self { + case .businessIntro(let flags, let title, let description, let sticker): + return ("businessIntro", [("flags", flags as Any), ("title", title as Any), ("description", description as Any), ("sticker", sticker as Any)]) + } + } + + public static func parse_businessIntro(_ reader: BufferReader) -> BusinessIntro? { + var _1: Int32? + _1 = reader.readInt32() + var _2: String? + _2 = parseString(reader) + var _3: String? + _3 = parseString(reader) + var _4: Api.Document? + if Int(_1!) & Int(1 << 0) != 0 {if let signature = reader.readInt32() { + _4 = Api.parse(reader, signature: signature) as? Api.Document + } } + let _c1 = _1 != nil + let _c2 = _2 != nil + let _c3 = _3 != nil + let _c4 = (Int(_1!) & Int(1 << 0) == 0) || _4 != nil + if _c1 && _c2 && _c3 && _c4 { + return Api.BusinessIntro.businessIntro(flags: _1!, title: _2!, description: _3!, sticker: _4) + } + else { + return nil + } + } + + } +} public extension Api { enum BusinessLocation: TypeConstructorDescription { case businessLocation(flags: Int32, geoPoint: Api.GeoPoint?, address: String) diff --git a/submodules/TelegramApi/Sources/Api36.swift b/submodules/TelegramApi/Sources/Api36.swift index fbd9e0b3b2..6c886c3735 100644 --- a/submodules/TelegramApi/Sources/Api36.swift +++ b/submodules/TelegramApi/Sources/Api36.swift @@ -2201,17 +2201,17 @@ public extension Api.functions.auth { } } public extension Api.functions.bots { - static func addPreviewMedia(bot: Api.InputUser, langCode: String, media: Api.InputMedia) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func addPreviewMedia(bot: Api.InputUser, langCode: String, media: Api.InputMedia) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(911238190) + buffer.appendInt32(397326170) bot.serialize(buffer, true) serializeString(langCode, buffer: buffer, boxed: false) media.serialize(buffer, true) - return (FunctionDescription(name: "bots.addPreviewMedia", parameters: [("bot", String(describing: bot)), ("langCode", String(describing: langCode)), ("media", String(describing: media))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.MessageMedia? in + return (FunctionDescription(name: "bots.addPreviewMedia", parameters: [("bot", String(describing: bot)), ("langCode", String(describing: langCode)), ("media", String(describing: media))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.BotPreviewMedia? in let reader = BufferReader(buffer) - var result: Api.MessageMedia? + var result: Api.BotPreviewMedia? if let signature = reader.readInt32() { - result = Api.parse(reader, signature: signature) as? Api.MessageMedia + result = Api.parse(reader, signature: signature) as? Api.BotPreviewMedia } return result }) @@ -2285,18 +2285,18 @@ public extension Api.functions.bots { } } public extension Api.functions.bots { - static func editPreviewMedia(bot: Api.InputUser, langCode: String, media: Api.InputMedia, newMedia: Api.InputMedia) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func editPreviewMedia(bot: Api.InputUser, langCode: String, media: Api.InputMedia, newMedia: Api.InputMedia) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(1892426154) + buffer.appendInt32(-2061148049) bot.serialize(buffer, true) serializeString(langCode, buffer: buffer, boxed: false) media.serialize(buffer, true) newMedia.serialize(buffer, true) - return (FunctionDescription(name: "bots.editPreviewMedia", parameters: [("bot", String(describing: bot)), ("langCode", String(describing: langCode)), ("media", String(describing: media)), ("newMedia", String(describing: newMedia))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.MessageMedia? in + return (FunctionDescription(name: "bots.editPreviewMedia", parameters: [("bot", String(describing: bot)), ("langCode", String(describing: langCode)), ("media", String(describing: media)), ("newMedia", String(describing: newMedia))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.BotPreviewMedia? in let reader = BufferReader(buffer) - var result: Api.MessageMedia? + var result: Api.BotPreviewMedia? if let signature = reader.readInt32() { - result = Api.parse(reader, signature: signature) as? Api.MessageMedia + result = Api.parse(reader, signature: signature) as? Api.BotPreviewMedia } return result }) @@ -2383,15 +2383,15 @@ public extension Api.functions.bots { } } public extension Api.functions.bots { - static func getPreviewMedias(bot: Api.InputUser) -> (FunctionDescription, Buffer, DeserializeFunctionResponse<[Api.MessageMedia]>) { + static func getPreviewMedias(bot: Api.InputUser) -> (FunctionDescription, Buffer, DeserializeFunctionResponse<[Api.BotPreviewMedia]>) { let buffer = Buffer() - buffer.appendInt32(1720252591) + buffer.appendInt32(-1566222003) bot.serialize(buffer, true) - return (FunctionDescription(name: "bots.getPreviewMedias", parameters: [("bot", String(describing: bot))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> [Api.MessageMedia]? in + return (FunctionDescription(name: "bots.getPreviewMedias", parameters: [("bot", String(describing: bot))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> [Api.BotPreviewMedia]? in let reader = BufferReader(buffer) - var result: [Api.MessageMedia]? + var result: [Api.BotPreviewMedia]? if let _ = reader.readInt32() { - result = Api.parseVector(reader, elementSignature: 0, elementType: Api.MessageMedia.self) + result = Api.parseVector(reader, elementSignature: 0, elementType: Api.BotPreviewMedia.self) } return result }) diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index 9fa6beed64..f2f7f5321f 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -287,6 +287,11 @@ public final class AccountStateManager { return self.storyUpdatesPipe.signal() } + fileprivate let botPreviewUpdatesPipe = ValuePipe<[InternalBotPreviewUpdate]>() + public var botPreviewUpdates: Signal<[InternalBotPreviewUpdate], NoError> { + return self.botPreviewUpdatesPipe.signal() + } + private var updatedWebpageContexts: [MediaId: UpdatedWebpageSubscriberContext] = [:] private var updatedPeersNearbyContext = UpdatedPeersNearbySubscriberContext() private var updatedRevenueBalancesContext = UpdatedRevenueBalancesSubscriberContext() @@ -1856,6 +1861,18 @@ public final class AccountStateManager { } } + var botPreviewUpdates: Signal<[InternalBotPreviewUpdate], NoError> { + return self.impl.signalWith { impl, subscriber in + return impl.botPreviewUpdates.start(next: subscriber.putNext, error: subscriber.putError, completed: subscriber.putCompletion) + } + } + + func injectBotPreviewUpdates(updates: [InternalBotPreviewUpdate]) { + self.impl.with { impl in + impl.botPreviewUpdatesPipe.putNext(updates) + } + } + var updateConfigRequested: (() -> Void)? var isPremiumUpdated: (() -> Void)? diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift index 62c160c7c8..f35daeb38a 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift @@ -627,40 +627,87 @@ extension TelegramBusinessChatLinks { public final class CachedUserData: CachedPeerData { public final class BotPreview: Codable, Equatable { private enum CodingKeys: String, CodingKey { - case media + case items + case alternativeLanguageCodes } - public let media: [Media] + public final class Item: Codable, Equatable { + private enum CodingKeys: String, CodingKey { + case media = "m" + case timestamp = "t" + } + + public let media: Media + public let timestamp: Int32 + + public init(media: Media, timestamp: Int32) { + self.media = media + self.timestamp = timestamp + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let mediaData = try container.decode(Data.self, forKey: .media) + guard let media = PostboxDecoder(buffer: MemoryBuffer(data: mediaData)).decodeRootObject() as? Media else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "media")) + } + self.media = media + + self.timestamp = try container.decode(Int32.self, forKey: .timestamp) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + let encoder = PostboxEncoder() + encoder.encodeRootObject(media) + try container.encode(encoder.makeData(), forKey: .media) + + try container.encode(self.timestamp, forKey: .timestamp) + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs === rhs { + return true + } + if !lhs.media.isEqual(to: rhs.media) { + return false + } + return true + } + } - public init(media: [Media]) { - self.media = media + public let items: [Item] + public let alternativeLanguageCodes: [String] + + public init(items: [Item], alternativeLanguageCodes: [String]) { + self.items = items + self.alternativeLanguageCodes = alternativeLanguageCodes } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let mediaData = try container.decode([Data].self, forKey: .media) - self.media = mediaData.compactMap { data -> Media? in - return PostboxDecoder(buffer: MemoryBuffer(data: data)).decodeRootObject() as? Media - } + self.items = try container.decode([Item].self, forKey: .items) + self.alternativeLanguageCodes = try container.decode([String].self, forKey: .alternativeLanguageCodes) } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - let mediaData = self.media.map { media -> Data in - let encoder = PostboxEncoder() - encoder.encodeRootObject(media) - return encoder.makeData() - } - try container.encode(mediaData, forKey: .media) + try container.encode(self.items, forKey: .items) + try container.encode(self.alternativeLanguageCodes, forKey: .alternativeLanguageCodes) } public static func ==(lhs: BotPreview, rhs: BotPreview) -> Bool { if lhs === rhs { return true } - if !areMediaArraysEqual(lhs.media, rhs.media) { + if lhs.items != rhs.items { + return false + } + if lhs.alternativeLanguageCodes != rhs.alternativeLanguageCodes { return false } return true diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift index 2100467d23..cf8b471f2c 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/PendingStoryManager.swift @@ -8,11 +8,12 @@ public extension Stories { private enum CodingKeys: String, CodingKey { case discriminator = "tt" case peerId = "peerId" + case language = "language" } case myStories case peer(PeerId) - case botPreview(PeerId) + case botPreview(id: PeerId, language: String?) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -23,7 +24,7 @@ public extension Stories { case 1: self = .peer(try container.decode(PeerId.self, forKey: .peerId)) case 2: - self = .botPreview(try container.decode(PeerId.self, forKey: .peerId)) + self = .botPreview(id: try container.decode(PeerId.self, forKey: .peerId), language: try container.decodeIfPresent(String.self, forKey: .language)) default: self = .myStories } @@ -38,9 +39,10 @@ public extension Stories { case let .peer(peerId): try container.encode(1 as Int32, forKey: .discriminator) try container.encode(peerId, forKey: .peerId) - case let .botPreview(peerId): + case let .botPreview(peerId, language): try container.encode(2 as Int32, forKey: .discriminator) try container.encode(peerId, forKey: .peerId) + try container.encodeIfPresent(language, forKey: .language) } } } @@ -406,13 +408,15 @@ final class PendingStoryManager { let toPeerId: PeerId var isBotPreview = false + var botPreviewLanguage: String? switch firstItem.target { case .myStories: toPeerId = self.accountPeerId case let .peer(peerId): toPeerId = peerId - case let .botPreview(peerId): + case let .botPreview(peerId, language): toPeerId = peerId + botPreviewLanguage = language isBotPreview = true } @@ -427,6 +431,7 @@ final class PendingStoryManager { revalidationContext: self.revalidationContext, auxiliaryMethods: self.auxiliaryMethods, toPeerId: toPeerId, + language: botPreviewLanguage, stableId: stableId, media: firstItem.media, mediaAreas: firstItem.mediaAreas, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index d945215da9..4c0b7329cb 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -1270,6 +1270,7 @@ func _internal_uploadBotPreviewImpl( revalidationContext: MediaReferenceRevalidationContext, auxiliaryMethods: AccountAuxiliaryMethods, toPeerId: PeerId, + language: String?, stableId: Int32, media: Media, mediaAreas: [MediaArea], @@ -1300,46 +1301,59 @@ func _internal_uploadBotPreviewImpl( return postbox.transaction { transaction -> Signal in switch content.content { case let .media(inputMedia, _): - return network.request(Api.functions.bots.addPreviewMedia(bot: inputUser, langCode: "", media: inputMedia)) + return network.request(Api.functions.bots.addPreviewMedia(bot: inputUser, langCode: language ?? "", media: inputMedia)) |> map(Optional.init) - |> `catch` { _ -> Signal in + |> `catch` { _ -> Signal in return .single(nil) } - |> mapToSignal { resultMedia -> Signal in - return postbox.transaction { transaction -> StoryUploadResult in - var currentState: Stories.LocalState - if let value = transaction.getLocalStoryState()?.get(Stories.LocalState.self) { - currentState = value - } else { - currentState = Stories.LocalState(items: []) - } - if let index = currentState.items.firstIndex(where: { $0.stableId == stableId }) { - currentState.items.remove(at: index) - transaction.setLocalStoryState(state: CodableEntry(currentState)) - } - - if let resultMediaValue = textMediaAndExpirationTimerFromApiMedia(resultMedia, toPeerId).media { - applyMediaResourceChanges(from: originalMedia, to: resultMediaValue, postbox: postbox, force: originalMedia is TelegramMediaFile && resultMediaValue is TelegramMediaFile) + |> mapToSignal { resultPreviewMedia -> Signal in + guard let resultPreviewMedia else { + return .single(.completed(nil)) + } + switch resultPreviewMedia { + case let .botPreviewMedia(date, resultMedia): + return postbox.transaction { transaction -> StoryUploadResult in + var currentState: Stories.LocalState + if let value = transaction.getLocalStoryState()?.get(Stories.LocalState.self) { + currentState = value + } else { + currentState = Stories.LocalState(items: []) + } + if let index = currentState.items.firstIndex(where: { $0.stableId == stableId }) { + currentState.items.remove(at: index) + transaction.setLocalStoryState(state: CodableEntry(currentState)) + } - transaction.updatePeerCachedData(peerIds: Set([toPeerId]), update: { _, current in - guard var current = current as? CachedUserData else { - return current + if let resultMediaValue = textMediaAndExpirationTimerFromApiMedia(resultMedia, toPeerId).media { + applyMediaResourceChanges(from: originalMedia, to: resultMediaValue, postbox: postbox, force: originalMedia is TelegramMediaFile && resultMediaValue is TelegramMediaFile) + + let addedItem = CachedUserData.BotPreview.Item(media: resultMediaValue, timestamp: date) + + if language == nil { + transaction.updatePeerCachedData(peerIds: Set([toPeerId]), update: { _, current in + guard var current = current as? CachedUserData else { + return current + } + guard let currentBotPreview = current.botPreview else { + return current + } + var items = currentBotPreview.items + if let index = items.firstIndex(where: { $0.media.id == resultMediaValue.id }) { + items.remove(at: index) + } + items.insert(addedItem, at: 0) + let botPreview = CachedUserData.BotPreview(items: items, alternativeLanguageCodes: currentBotPreview.alternativeLanguageCodes) + current = current.withUpdatedBotPreview(botPreview) + return current + }) } - guard let currentBotPreview = current.botPreview else { - return current - } - var media = currentBotPreview.media - if let index = media.firstIndex(where: { $0.id == resultMediaValue.id }) { - media.remove(at: index) - } - media.insert(resultMediaValue, at: 0) - let botPreview = CachedUserData.BotPreview(media: media) - current = current.withUpdatedBotPreview(botPreview) - return current - }) + stateManager.injectBotPreviewUpdates(updates: [ + .added(peerId: toPeerId, language: language, item: addedItem) + ]) + } + + return .completed(nil) } - - return .completed(nil) } } default: @@ -1354,13 +1368,79 @@ func _internal_uploadBotPreviewImpl( } } -func _internal_deleteBotPreviews(account: Account, peerId: PeerId, ids: [MediaId]) -> Signal { +func _internal_deleteBotPreviews(account: Account, peerId: PeerId, language: String?, media: [Media]) -> Signal { return account.postbox.transaction { transaction -> (Api.InputUser?, [Api.InputMedia]) in guard let inputPeer = transaction.getPeer(peerId).flatMap(apiInputUser) else { return (nil, []) } var inputMedia: [Api.InputMedia] = [] + for item in media { + if let image = item as? TelegramMediaImage, let resource = image.representations.last?.resource as? CloudPhotoSizeMediaResource { + inputMedia.append(.inputMediaPhoto(flags: 0, id: .inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + inputMedia.append(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + } else if let file = item as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource { + inputMedia.append(.inputMediaDocument(flags: 0, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: nil)) + } + } + if language == nil { + transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current -> CachedPeerData? in + guard var current = current as? CachedUserData else { + return current + } + guard let currentBotPreview = current.botPreview else { + return current + } + var items = currentBotPreview.items + + items = items.filter({ item in + guard let id = item.media.id else { + return false + } + return !media.contains(where: { $0.id == id }) + }) + let botPreview = CachedUserData.BotPreview(items: items, alternativeLanguageCodes: currentBotPreview.alternativeLanguageCodes) + current = current.withUpdatedBotPreview(botPreview) + return current + }) + } + + return (inputPeer, inputMedia) + } + |> mapToSignal { inputPeer, inputMedia -> Signal in + guard let inputPeer else { + return .complete() + } + + account.stateManager.injectBotPreviewUpdates(updates: [ + .deleted(peerId: peerId, language: language, ids: media.compactMap(\.id)) + ]) + + return account.network.request(Api.functions.bots.deletePreviewMedia(bot: inputPeer, langCode: language ?? "", media: inputMedia)) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> mapToSignal { _ -> Signal in + return .complete() + } + } +} + +func _internal_deleteBotPreviewsLanguage(account: Account, peerId: PeerId, language: String, media: [Media]) -> Signal { + return account.postbox.transaction { transaction -> (Api.InputUser?, [Api.InputMedia]) in + guard let inputPeer = transaction.getPeer(peerId).flatMap(apiInputUser) else { + return (nil, []) + } + + var inputMedia: [Api.InputMedia] = [] + for item in media { + if let image = item as? TelegramMediaImage, let resource = image.representations.last?.resource as? CloudPhotoSizeMediaResource { + inputMedia.append(.inputMediaPhoto(flags: 0, id: .inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + inputMedia.append(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + } else if let file = item as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource { + inputMedia.append(.inputMediaDocument(flags: 0, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: nil)) + } + } transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, current -> CachedPeerData? in guard var current = current as? CachedUserData else { return current @@ -1368,29 +1448,11 @@ func _internal_deleteBotPreviews(account: Account, peerId: PeerId, ids: [MediaId guard let currentBotPreview = current.botPreview else { return current } - var media = currentBotPreview.media - - for item in media { - guard let id = item.id else { - continue - } - if ids.contains(id) { - if let image = item as? TelegramMediaImage, let resource = image.representations.last?.resource as? CloudPhotoSizeMediaResource { - inputMedia.append(.inputMediaPhoto(flags: 0, id: .inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) - inputMedia.append(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) - } else if let file = item as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource { - inputMedia.append(.inputMediaDocument(flags: 0, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: nil)) - } - } + var alternativeLanguageCodes = currentBotPreview.alternativeLanguageCodes + alternativeLanguageCodes = alternativeLanguageCodes.filter { item in + return item != language } - - media = media.filter({ item in - guard let id = item.id else { - return false - } - return !ids.contains(id) - }) - let botPreview = CachedUserData.BotPreview(media: media) + let botPreview = CachedUserData.BotPreview(items: currentBotPreview.items, alternativeLanguageCodes: alternativeLanguageCodes) current = current.withUpdatedBotPreview(botPreview) return current }) @@ -1402,7 +1464,11 @@ func _internal_deleteBotPreviews(account: Account, peerId: PeerId, ids: [MediaId return .complete() } - return account.network.request(Api.functions.bots.deletePreviewMedia(bot: inputPeer, langCode: "", media: inputMedia)) + account.stateManager.injectBotPreviewUpdates(updates: [ + .deleted(peerId: peerId, language: language, ids: media.compactMap(\.id)) + ]) + + return account.network.request(Api.functions.bots.deletePreviewMedia(bot: inputPeer, langCode: language, media: inputMedia)) |> `catch` { _ -> Signal in return .single(.boolFalse) } @@ -1623,8 +1689,8 @@ func _internal_checkStoriesUploadAvailability(account: Account, target: Stories. return .inputPeerSelf case let .peer(peerId): return transaction.getPeer(peerId).flatMap(apiInputPeer) - case let .botPreview(peerId): - return transaction.getPeer(peerId).flatMap(apiInputPeer) + case .botPreview: + return nil } } |> mapToSignal { inputPeer -> Signal in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index 3c2ebe5c4e..296abd0c85 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -12,6 +12,11 @@ enum InternalStoryUpdate { case updateMyReaction(peerId: PeerId, id: Int32, reaction: MessageReaction.Reaction?) } +enum InternalBotPreviewUpdate { + case added(peerId: PeerId, language: String?, item: CachedUserData.BotPreview.Item) + case deleted(peerId: PeerId, language: String?, ids: [MediaId]) +} + public final class EngineStoryItem: Equatable { public final class Views: Equatable { public let seenCount: Int @@ -563,8 +568,19 @@ public struct StoryListContextState: Equatable { } } + public struct Language: Equatable { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } + } + public var peerReference: PeerReference? public var items: [Item] + public var availableLanguages: [Language] public var pinnedIds: [Int32] public var totalCount: Int public var loadMoreToken: AnyHashable? @@ -575,6 +591,7 @@ public struct StoryListContextState: Equatable { public init( peerReference: PeerReference?, items: [Item], + availableLanguages: [Language], pinnedIds: [Int32], totalCount: Int, loadMoreToken: AnyHashable?, @@ -585,6 +602,7 @@ public struct StoryListContextState: Equatable { ) { self.peerReference = peerReference self.items = items + self.availableLanguages = availableLanguages self.pinnedIds = pinnedIds self.totalCount = totalCount self.loadMoreToken = loadMoreToken @@ -633,7 +651,7 @@ public final class PeerStoryListContext: StoryListContext { self.peerId = peerId self.isArchived = isArchived - self.stateValue = State(peerReference: nil, items: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) let _ = (account.postbox.transaction { transaction -> (PeerReference?, [State.Item], [Int32], Int, [MediaId: TelegramMediaFile], Bool) in let key = ValueBoxKey(length: 8 + 1) @@ -723,7 +741,7 @@ public final class PeerStoryListContext: StoryListContext { return } - var updatedState = State(peerReference: peerReference, items: items, pinnedIds: pinnedIds, totalCount: totalCount, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: hasCache, allEntityFiles: allEntityFiles, isLoading: false) + var updatedState = State(peerReference: peerReference, items: items, availableLanguages: [], pinnedIds: pinnedIds, totalCount: totalCount, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: hasCache, allEntityFiles: allEntityFiles, isLoading: false) updatedState.items.sort(by: { lhs, rhs in let lhsPinned = updatedState.pinnedIds.firstIndex(of: lhs.storyItem.id) let rhsPinned = updatedState.pinnedIds.firstIndex(of: rhs.storyItem.id) @@ -746,6 +764,7 @@ public final class PeerStoryListContext: StoryListContext { deinit { self.requestDisposable?.dispose() + self.updatesDisposable?.dispose() } func loadMore(completion: (() -> Void)?) { @@ -1313,7 +1332,7 @@ public final class SearchStoryListContext: StoryListContext { self.account = account self.source = source - self.stateValue = State(peerReference: nil, items: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(""), isCached: false, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(""), isCached: false, hasCache: false, allEntityFiles: [:], isLoading: false) self.statePromise.set(.single(self.stateValue)) self.loadMore(completion: nil) @@ -2078,6 +2097,7 @@ public final class BotPreviewStoryListContext: StoryListContext { private let account: Account private let engine: TelegramEngine private let peerId: EnginePeer.Id + private let language: String? private let isArchived: Bool private let statePromise = Promise() @@ -2093,6 +2113,7 @@ public final class BotPreviewStoryListContext: StoryListContext { private var isLoadingMore: Bool = false private var requestDisposable: Disposable? private var updatesDisposable: Disposable? + private var eventsDisposable: Disposable? private let reorderDisposable = MetaDisposable() private var completionCallbacksByToken: [AnyHashable: [() -> Void]] = [:] @@ -2102,36 +2123,305 @@ public final class BotPreviewStoryListContext: StoryListContext { private var idMapping: [MediaId: Int32] = [:] private var reverseIdMapping: [Int32: MediaId] = [:] - init(queue: Queue, account: Account, engine: TelegramEngine, peerId: EnginePeer.Id) { + private var localItems: [State.Item] = [] + private var remoteItems: [State.Item] = [] + + init(queue: Queue, account: Account, engine: TelegramEngine, peerId: EnginePeer.Id, language: String?, assumeEmpty: Bool) { self.queue = queue self.account = account self.engine = engine self.peerId = peerId + self.language = language let isArchived = false self.isArchived = isArchived - self.stateValue = State(peerReference: nil, items: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) let localStateKey: PostboxViewKey = .storiesState(key: .local) - self.requestDisposable = (combineLatest(queue: queue, - engine.data.subscribe( - TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), - TelegramEngine.EngineData.Item.Peer.BotPreview(id: peerId) - ), - account.postbox.combinedView(keys: [localStateKey]) - ) - |> deliverOn(self.queue)).start(next: { [weak self] peerAndBotPreview, combinedView in + if let language { + let _ = (account.postbox.transaction { transaction -> Peer? in + return transaction.getPeer(peerId) + } + |> deliverOn(self.queue)).start(next: { [weak self] peer in + guard let self else { + return + } + + self.stateValue = State( + peerReference: peer.flatMap(PeerReference.init), + items: [], + availableLanguages: [], + pinnedIds: [], + totalCount: 0, + loadMoreToken: AnyHashable(0), + isCached: assumeEmpty, + hasCache: assumeEmpty, + allEntityFiles: [:], + isLoading: !assumeEmpty + ) + + self.loadLanguage(language: language, assumeEmpty: assumeEmpty) + }) + } else { + self.requestDisposable = (combineLatest(queue: queue, + engine.data.subscribe( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Peer.BotPreview(id: peerId), + TelegramEngine.EngineData.Item.Configuration.LocalizationList() + ), + account.postbox.combinedView(keys: [ + localStateKey + ]) + ) + |> deliverOn(self.queue)).start(next: { [weak self] peerAndBotPreview, combinedView in + guard let self else { + return + } + + let (peer, botPreview, localizationList) = peerAndBotPreview + + var items: [State.Item] = [] + var availableLanguages: [StoryListContextState.Language] = [] + + if let stateView = combinedView.views[localStateKey] as? StoryStatesView, let localState = stateView.value?.get(Stories.LocalState.self) { + for item in localState.items.reversed() { + let mappedId: Int32 + if let current = self.pendingIdMapping[item.stableId] { + mappedId = current + } else { + mappedId = self.nextId + self.nextId += 1 + self.pendingIdMapping[item.stableId] = mappedId + } + if case let .botPreview(itemPeerId, itemLanguage) = item.target, itemPeerId == peerId, itemLanguage == language { + items.append(State.Item( + id: StoryId(peerId: peerId, id: mappedId), + storyItem: EngineStoryItem( + id: mappedId, + timestamp: 0, + expirationTimestamp: Int32.max, + media: EngineMedia(item.media), + alternativeMedia: nil, + mediaAreas: [], + text: "", + entities: [], + views: nil, + privacy: nil, + isPinned: false, + isExpired: false, + isPublic: false, + isPending: true, + isCloseFriends: false, + isContacts: false, + isSelectedContacts: false, + isForwardingDisabled: false, + isEdited: false, + isMy: false, + myReaction: nil, + forwardInfo: nil, + author: nil + ), + peer: nil + )) + } + } + } + + if let botPreview { + for item in botPreview.items { + guard let mediaId = item.media.id else { + continue + } + + let id: Int32 + if let current = self.idMapping[mediaId] { + id = current + } else { + id = self.nextId + self.nextId += 1 + self.idMapping[mediaId] = id + self.reverseIdMapping[id] = mediaId + } + + items.append(State.Item( + id: StoryId(peerId: peerId, id: id), + storyItem: EngineStoryItem( + id: id, + timestamp: item.timestamp, + expirationTimestamp: Int32.max, + media: EngineMedia(item.media), + alternativeMedia: nil, + mediaAreas: [], + text: "", + entities: [], + views: nil, + privacy: nil, + isPinned: false, + isExpired: false, + isPublic: false, + isPending: false, + isCloseFriends: false, + isContacts: false, + isSelectedContacts: false, + isForwardingDisabled: false, + isEdited: false, + isMy: false, + myReaction: nil, + forwardInfo: nil, + author: nil + ), + peer: nil + )) + } + + for id in botPreview.alternativeLanguageCodes { + inner: for localization in localizationList.availableOfficialLocalizations { + if localization.languageCode == id { + availableLanguages.append(StoryListContextState.Language( + id: localization.languageCode, + name: localization.title + )) + break inner + } + } + } + } + + self.stateValue = State( + peerReference: (peer?._asPeer()).flatMap(PeerReference.init), + items: items, + availableLanguages: availableLanguages, + pinnedIds: [], + totalCount: items.count, + loadMoreToken: nil, + isCached: botPreview != nil, + hasCache: botPreview != nil, + allEntityFiles: [:], + isLoading: botPreview == nil + ) + }) + } + } + + deinit { + self.requestDisposable?.dispose() + self.updatesDisposable?.dispose() + self.eventsDisposable?.dispose() + self.reorderDisposable.dispose() + } + + func loadMore(completion: (() -> Void)?) { + } + + private func loadLanguage(language: String, assumeEmpty: Bool) { + let account = self.account + let peerId = self.peerId + let signal: Signal<(CachedUserData.BotPreview?, Peer?), NoError> = (self.account.postbox.transaction { transaction -> Peer? in + return transaction.getPeer(peerId) + } + |> mapToSignal { peer -> Signal<(CachedUserData.BotPreview?, Peer?), NoError> in + guard let peer, let inputUser = apiInputUser(peer) else { + return .single((nil, nil)) + } + return _internal_requestBotPreview(network: account.network, peerId: peerId, inputUser: inputUser, language: language) + |> map { botPreview in + return (botPreview, peer) + } + }) + + self.requestDisposable?.dispose() + self.requestDisposable = (signal + |> deliverOn(self.queue)).startStrict(next: { [weak self] botPreview, peer in + guard let self, let peer else { + return + } + + var items: [State.Item] = [] + + if let botPreview { + for item in botPreview.items { + guard let mediaId = item.media.id else { + continue + } + + let id: Int32 + if let current = self.idMapping[mediaId] { + id = current + } else { + id = self.nextId + self.nextId += 1 + self.idMapping[mediaId] = id + self.reverseIdMapping[id] = mediaId + } + + items.append(State.Item( + id: StoryId(peerId: peerId, id: id), + storyItem: EngineStoryItem( + id: id, + timestamp: item.timestamp, + expirationTimestamp: Int32.max, + media: EngineMedia(item.media), + alternativeMedia: nil, + mediaAreas: [], + text: "", + entities: [], + views: nil, + privacy: nil, + isPinned: false, + isExpired: false, + isPublic: false, + isPending: false, + isCloseFriends: false, + isContacts: false, + isSelectedContacts: false, + isForwardingDisabled: false, + isEdited: false, + isMy: false, + myReaction: nil, + forwardInfo: nil, + author: nil + ), + peer: nil + )) + } + } + + self.remoteItems = items + self.stateValue = State( + peerReference: PeerReference(peer), + items: items, + availableLanguages: [], + pinnedIds: [], + totalCount: items.count, + loadMoreToken: nil, + isCached: botPreview != nil, + hasCache: botPreview != nil, + allEntityFiles: [:], + isLoading: botPreview == nil + ) + + if botPreview != nil { + self.beginUpdates(language: language) + } + }) + } + + private func beginUpdates(language: String) { + let localStateKey: PostboxViewKey = .storiesState(key: .local) + + self.updatesDisposable?.dispose() + self.updatesDisposable = (self.account.postbox.combinedView(keys: [ + localStateKey + ]) + |> deliverOn(self.queue)).startStrict(next: { [weak self] combinedView in guard let self else { return } - let (peer, botPreview) = peerAndBotPreview - var items: [State.Item] = [] - if let stateView = combinedView.views[localStateKey] as? StoryStatesView, let localState = stateView.value?.get(Stories.LocalState.self) { for item in localState.items.reversed() { let mappedId: Int32 @@ -2142,7 +2432,7 @@ public final class BotPreviewStoryListContext: StoryListContext { self.nextId += 1 self.pendingIdMapping[item.stableId] = mappedId } - if case .botPreview(peerId) = item.target { + if case let .botPreview(itemPeerId, itemLanguage) = item.target, itemPeerId == self.peerId, itemLanguage == language { items.append(State.Item( id: StoryId(peerId: peerId, id: mappedId), storyItem: EngineStoryItem( @@ -2176,130 +2466,160 @@ public final class BotPreviewStoryListContext: StoryListContext { } } - if let botPreview { - for media in botPreview.media { - guard let mediaId = media.id else { - continue - } - - let id: Int32 - if let current = self.idMapping[mediaId] { - id = current - } else { - id = self.nextId - self.nextId += 1 - self.idMapping[mediaId] = id - self.reverseIdMapping[id] = mediaId - } - - items.append(State.Item( - id: StoryId(peerId: peerId, id: id), - storyItem: EngineStoryItem( - id: id, - timestamp: 0, - expirationTimestamp: Int32.max, - media: EngineMedia(media), - alternativeMedia: nil, - mediaAreas: [], - text: "", - entities: [], - views: nil, - privacy: nil, - isPinned: false, - isExpired: false, - isPublic: false, - isPending: false, - isCloseFriends: false, - isContacts: false, - isSelectedContacts: false, - isForwardingDisabled: false, - isEdited: false, - isMy: false, - myReaction: nil, - forwardInfo: nil, - author: nil - ), - peer: nil - )) + if self.localItems != items { + self.localItems = items + + if self.stateValue.peerReference != nil { + self.pushLanguageItems() } } - - self.stateValue = State( - peerReference: (peer?._asPeer()).flatMap(PeerReference.init), - items: items, - pinnedIds: [], - totalCount: items.count, - loadMoreToken: nil, - isCached: botPreview != nil, - hasCache: botPreview != nil, - allEntityFiles: [:], - isLoading: botPreview == nil - ) + }) + + self.eventsDisposable?.dispose() + self.eventsDisposable = (self.account.stateManager.botPreviewUpdates + |> deliverOn(self.queue)).startStrict(next: { [weak self] events in + guard let self else { + return + } + var remoteItems = self.remoteItems + for event in events { + switch event { + case let .added(peerId, language, item): + if let mediaId = item.media.id, self.peerId == peerId, self.language == language { + let id: Int32 + if let current = self.idMapping[mediaId] { + id = current + } else { + id = self.nextId + self.nextId += 1 + self.idMapping[mediaId] = id + self.reverseIdMapping[id] = mediaId + } + + let mappedItem = State.Item( + id: StoryId(peerId: peerId, id: id), + storyItem: EngineStoryItem( + id: id, + timestamp: item.timestamp, + expirationTimestamp: Int32.max, + media: EngineMedia(item.media), + alternativeMedia: nil, + mediaAreas: [], + text: "", + entities: [], + views: nil, + privacy: nil, + isPinned: false, + isExpired: false, + isPublic: false, + isPending: false, + isCloseFriends: false, + isContacts: false, + isSelectedContacts: false, + isForwardingDisabled: false, + isEdited: false, + isMy: false, + myReaction: nil, + forwardInfo: nil, + author: nil + ), + peer: nil + ) + + if let index = remoteItems.firstIndex(where: { $0.storyItem.media.id == item.media.id }) { + remoteItems[index] = mappedItem + } else { + remoteItems.insert(mappedItem, at: 0) + } + } + case let .deleted(peerId, language, ids): + if self.peerId == peerId && self.language == language { + remoteItems = remoteItems.filter { item in + guard let id = item.storyItem.media.id else { + return false + } + return !ids.contains(id) + } + } + } + } + if self.remoteItems != remoteItems { + self.remoteItems = remoteItems + self.pushLanguageItems() + } }) } - deinit { - self.requestDisposable?.dispose() - self.updatesDisposable?.dispose() - self.reorderDisposable.dispose() + private func pushLanguageItems() { + var items = self.localItems + items.append(contentsOf: self.remoteItems) + self.stateValue = State( + peerReference: self.stateValue.peerReference, + items: items, + availableLanguages: [], + pinnedIds: [], + totalCount: items.count, + loadMoreToken: nil, + isCached: true, + hasCache: true, + allEntityFiles: [:], + isLoading: false + ) } - func loadMore(completion: (() -> Void)?) { - } - - func reorderItems(ids: [StoryId]) { + func reorderItems(media: [Media]) { let peerId = self.peerId - let idMapping = self.idMapping - let reverseIdMapping = self.reverseIdMapping + let language = self.language let _ = (self.account.postbox.transaction({ transaction -> (Api.InputUser?, [Api.InputMedia]) in let inputUser = transaction.getPeer(peerId).flatMap(apiInputUser) var inputMedia: [Api.InputMedia] = [] - transaction.updatePeerCachedData(peerIds: Set([self.peerId]), update: { _, current in - guard var current = current as? CachedUserData else { + for item in media { + if let image = item as? TelegramMediaImage, let resource = image.representations.last?.resource as? CloudPhotoSizeMediaResource { + inputMedia.append(.inputMediaPhoto(flags: 0, id: .inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + inputMedia.append(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) + } else if let file = item as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource { + inputMedia.append(.inputMediaDocument(flags: 0, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: nil)) + } + } + + if language == nil { + transaction.updatePeerCachedData(peerIds: Set([self.peerId]), update: { _, current in + guard var current = current as? CachedUserData else { + return current + } + guard let currentBotPreview = current.botPreview else { + return current + } + + var items: [CachedUserData.BotPreview.Item] = [] + + var seenIds = Set() + for item in media { + guard let mediaId = item.id else { + continue + } + if let index = currentBotPreview.items.firstIndex(where: { $0.media.id == mediaId }) { + seenIds.insert(mediaId) + items.append(currentBotPreview.items[index]) + } + } + + for item in currentBotPreview.items { + guard let id = item.media.id else { + continue + } + if !seenIds.contains(id) { + items.append(item) + } + } + + let botPreview = CachedUserData.BotPreview(items: items, alternativeLanguageCodes: currentBotPreview.alternativeLanguageCodes) + current = current.withUpdatedBotPreview(botPreview) return current - } - guard let currentBotPreview = current.botPreview else { - return current - } - - var media: [Media] = [] - media = [] - - var seenIds = Set() - for id in ids { - guard let mediaId = reverseIdMapping[id.id] else { - continue - } - if let index = currentBotPreview.media.firstIndex(where: { $0.id == mediaId }) { - seenIds.insert(id.id) - media.append(currentBotPreview.media[index]) - } - } - - for item in currentBotPreview.media { - guard let id = item.id, let storyId = idMapping[id] else { - continue - } - if !seenIds.contains(storyId) { - media.append(item) - } - } - - for item in media { - if let image = item as? TelegramMediaImage, let resource = image.representations.last?.resource as? CloudPhotoSizeMediaResource { - inputMedia.append(.inputMediaPhoto(flags: 0, id: .inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) - inputMedia.append(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: resource.photoId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), ttlSeconds: nil)) - } else if let file = item as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource { - inputMedia.append(.inputMediaDocument(flags: 0, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: nil)) - } - } - - let botPreview = CachedUserData.BotPreview(media: media) - current = current.withUpdatedBotPreview(botPreview) - return current - }) + }) + } return (inputUser, inputMedia) }) @@ -2307,7 +2627,37 @@ public final class BotPreviewStoryListContext: StoryListContext { guard let self, let inputUser else { return } - let signal = self.account.network.request(Api.functions.bots.reorderPreviewMedias(bot: inputUser, langCode: "", order: inputMedia)) + + if language != nil { + var updatedItems: [State.Item] = [] + + var seenIds = Set() + for item in media { + guard let mediaId = item.id else { + continue + } + if let index = self.remoteItems.firstIndex(where: { $0.storyItem.media.id == mediaId }) { + seenIds.insert(mediaId) + updatedItems.append(self.remoteItems[index]) + } + } + + for item in self.remoteItems { + guard let id = item.storyItem.media.id else { + continue + } + if !seenIds.contains(id) { + updatedItems.append(item) + } + } + + if self.remoteItems != updatedItems { + self.remoteItems = updatedItems + self.pushLanguageItems() + } + } + + let signal = self.account.network.request(Api.functions.bots.reorderPreviewMedias(bot: inputUser, langCode: language ?? "", order: inputMedia)) self.reorderDisposable.set(signal.startStrict()) }) } @@ -2322,11 +2672,15 @@ public final class BotPreviewStoryListContext: StoryListContext { private let queue: Queue private let impl: QueueLocalObject - public init(account: Account, engine: TelegramEngine, peerId: EnginePeer.Id) { + public let language: String? + + public init(account: Account, engine: TelegramEngine, peerId: EnginePeer.Id, language: String?, assumeEmpty: Bool) { + self.language = language + let queue = Queue.mainQueue() self.queue = queue self.impl = QueueLocalObject(queue: queue, generate: { - return Impl(queue: queue, account: account, engine: engine, peerId: peerId) + return Impl(queue: queue, account: account, engine: engine, peerId: peerId, language: language, assumeEmpty: assumeEmpty) }) } @@ -2336,9 +2690,9 @@ public final class BotPreviewStoryListContext: StoryListContext { } } - public func reorderItems(ids: [StoryId]) { + public func reorderItems(media: [Media]) { self.impl.with { impl in - impl.reorderItems(ids: ids) + impl.reorderItems(media: media) } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 3343528dd1..d9927f1c2e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -1373,8 +1373,12 @@ public extension TelegramEngine { return _internal_getStoryById(accountPeerId: self.account.peerId, postbox: self.account.postbox, network: self.account.network, peerId: peerId, id: id) } - public func deleteBotPreviews(peerId: EnginePeer.Id, ids: [MediaId]) -> Signal { - return _internal_deleteBotPreviews(account: self.account, peerId: peerId, ids: ids) + public func deleteBotPreviews(peerId: EnginePeer.Id, language: String?, media: [Media]) -> Signal { + return _internal_deleteBotPreviews(account: self.account, peerId: peerId, language: language, media: media) + } + + public func deleteBotPreviewsLanguage(peerId: EnginePeer.Id, language: String, media: [Media]) -> Signal { + return _internal_deleteBotPreviewsLanguage(account: self.account, peerId: peerId, language: language, media: media) } public func synchronouslyIsMessageDeletedInteractively(ids: [EngineMessage.Id]) -> [EngineMessage.Id] { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/UpdateCachedPeerData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/UpdateCachedPeerData.swift index 186ab62cb4..4f09b015a3 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/UpdateCachedPeerData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/UpdateCachedPeerData.swift @@ -199,16 +199,7 @@ func _internal_fetchAndUpdateCachedPeerData(accountPeerId: PeerId, peerId rawPee let botPreview: Signal if let user = maybePeer as? TelegramUser, let _ = user.botInfo { - botPreview = network.request(Api.functions.bots.getPreviewMedias(bot: inputUser)) - |> `catch` { _ -> Signal<[Api.MessageMedia], NoError> in - return .single([]) - } - |> map { result -> CachedUserData.BotPreview? in - return CachedUserData.BotPreview(media: result.compactMap { item -> Media? in - let value = textMediaAndExpirationTimerFromApiMedia(item, user.id) - return value.media - }) - } + botPreview = _internal_requestBotPreview(network: network, peerId: user.id, inputUser: inputUser, language: nil) } else { botPreview = .single(nil) } @@ -843,3 +834,33 @@ extension CachedPeerAutoremoveTimeout.Value { } } } + +func _internal_requestBotPreview(network: Network, peerId: PeerId, inputUser: Api.InputUser, language: String?) -> Signal { + return network.request(Api.functions.bots.getPreviewInfo(bot: inputUser, langCode: language ?? "")) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { result -> CachedUserData.BotPreview? in + guard let result else { + return nil + } + switch result { + case let .previewInfo(media, langCodes): + return CachedUserData.BotPreview( + items: media.compactMap { item -> CachedUserData.BotPreview.Item? in + switch item { + case let .botPreviewMedia(date, media): + let value = textMediaAndExpirationTimerFromApiMedia(media, peerId) + if let media = value.media { + return CachedUserData.BotPreview.Item(media: media, timestamp: date) + } else { + return nil + } + } + }, + alternativeLanguageCodes: langCodes + ) + } + } +} diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 3d2f70b061..675a306fea 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -458,6 +458,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatSendStarsScreen", "//submodules/TelegramUI/Components/MinimizedContainer", "//submodules/TelegramUI/Components/SpaceWarpView", + "//submodules/TelegramUI/Components/MiniAppListScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index a84afc3bc9..30c06596e2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -1841,7 +1841,7 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } } } else if case .tap = gesture { - item.controllerInteraction.clickThroughMessage() + item.controllerInteraction.clickThroughMessage(self.view, location) } else if case .doubleTap = gesture { if canAddMessageReactions(message: item.message) { item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index f8352540e1..afb7056b82 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -4576,7 +4576,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } } else if case .tap = gesture { - item.controllerInteraction.clickThroughMessage() + item.controllerInteraction.clickThroughMessage(self.view, location) } else if case .doubleTap = gesture { if canAddMessageReactions(message: item.message) { item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index 59b4284e66..f9c0c49728 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -961,7 +961,7 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco break } } else if case .tap = gesture { - self.item?.controllerInteraction.clickThroughMessage() + self.item?.controllerInteraction.clickThroughMessage(self.view, location) } } default: diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift index d119a50ed4..5dcd3a1f81 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -1586,7 +1586,7 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { return } - self.item?.controllerInteraction.clickThroughMessage() + self.item?.controllerInteraction.clickThroughMessage(self.view, location) case .longTap, .doubleTap, .secondaryTap: break case .hold: diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index 9db9e5f4ba..ae7e32a202 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -1405,7 +1405,7 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { } } } else if case .tap = gesture { - self.item?.controllerInteraction.clickThroughMessage() + self.item?.controllerInteraction.clickThroughMessage(self.view, location) } } default: diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 80da5266a9..b07d025dfc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -306,7 +306,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { if let context = self?.context, let navigationController = self?.getNavigationController() { let _ = context.sharedContext.navigateToForumThread(context: context, peerId: peerId, threadId: threadId, messageId: nil, navigationController: navigationController, activateInput: nil, scrollToEndIfExists: false, keepStack: .always).startStandalone() } - }, tapMessage: nil, clickThroughMessage: { }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false + }, tapMessage: nil, clickThroughMessage: { _, _ in }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, requestMessageActionCallback: { [weak self] messageId, _, _, _ in guard let self else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index 5cd1237b3f..e07264e9c3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -418,7 +418,7 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, openMessageContextActions: { _, _, _, _ in }, navigateToMessage: { _, _, _ in }, navigateToMessageStandalone: { _ in }, navigateToThreadMessage: { _, _, _ in }, tapMessage: { _ in - }, clickThroughMessage: { + }, clickThroughMessage: { _, _ in }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, requestMessageActionCallback: { _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, openMessageShareMenu: { _ in diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 4e783c6ef1..3fd747901f 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -180,7 +180,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let navigateToMessageStandalone: (MessageId) -> Void public let navigateToThreadMessage: (PeerId, Int64, MessageId?) -> Void public let tapMessage: ((Message) -> Void)? - public let clickThroughMessage: () -> Void + public let clickThroughMessage: (UIView?, CGPoint?) -> Void public let toggleMessagesSelection: ([MessageId], Bool) -> Void public let sendCurrentMessage: (Bool, ChatSendMessageEffect?) -> Void public let sendMessage: (String) -> Void @@ -309,7 +309,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol navigateToMessageStandalone: @escaping (MessageId) -> Void, navigateToThreadMessage: @escaping (PeerId, Int64, MessageId?) -> Void, tapMessage: ((Message) -> Void)?, - clickThroughMessage: @escaping () -> Void, + clickThroughMessage: @escaping (UIView?, CGPoint?) -> Void, toggleMessagesSelection: @escaping ([MessageId], Bool) -> Void, sendCurrentMessage: @escaping (Bool, ChatSendMessageEffect?) -> Void, sendMessage: @escaping (String) -> Void, diff --git a/submodules/TelegramUI/Components/EmptyStateIndicatorComponent/Sources/EmptyStateIndicatorComponent.swift b/submodules/TelegramUI/Components/EmptyStateIndicatorComponent/Sources/EmptyStateIndicatorComponent.swift index 47357705fc..38bc8689a4 100644 --- a/submodules/TelegramUI/Components/EmptyStateIndicatorComponent/Sources/EmptyStateIndicatorComponent.swift +++ b/submodules/TelegramUI/Components/EmptyStateIndicatorComponent/Sources/EmptyStateIndicatorComponent.swift @@ -13,25 +13,27 @@ public final class EmptyStateIndicatorComponent: Component { public let context: AccountContext public let theme: PresentationTheme public let animationName: String? - public let title: String + public let title: String? public let text: String public let actionTitle: String? public let fitToHeight: Bool public let action: () -> Void public let additionalActionTitle: String? public let additionalAction: () -> Void + public let additionalActionSeparator: String? public init( context: AccountContext, theme: PresentationTheme, fitToHeight: Bool, animationName: String?, - title: String, + title: String?, text: String, actionTitle: String?, action: @escaping () -> Void, additionalActionTitle: String?, - additionalAction: @escaping () -> Void + additionalAction: @escaping () -> Void, + additionalActionSeparator: String? = nil ) { self.context = context self.theme = theme @@ -43,6 +45,7 @@ public final class EmptyStateIndicatorComponent: Component { self.action = action self.additionalActionTitle = additionalActionTitle self.additionalAction = additionalAction + self.additionalActionSeparator = additionalActionSeparator } public static func ==(lhs: EmptyStateIndicatorComponent, rhs: EmptyStateIndicatorComponent) -> Bool { @@ -70,6 +73,9 @@ public final class EmptyStateIndicatorComponent: Component { if lhs.additionalActionTitle != rhs.additionalActionTitle { return false } + if lhs.additionalActionSeparator != rhs.additionalActionSeparator { + return false + } return true } @@ -82,6 +88,9 @@ public final class EmptyStateIndicatorComponent: Component { private let text = ComponentView() private var button: ComponentView? private var additionalButton: ComponentView? + private var additionalSeparatorLeft: SimpleLayer? + private var additionalSeparatorRight: SimpleLayer? + private var additionalSeparatorText: ComponentView? override public init(frame: CGRect) { super.init(frame: frame) @@ -108,16 +117,20 @@ public final class EmptyStateIndicatorComponent: Component { containerSize: CGSize(width: 120.0, height: 120.0) ) } - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)), - horizontalAlignment: .center, - maximumNumberOfLines: 0 - )), - environment: {}, - containerSize: CGSize(width: min(300.0, availableSize.width - 16.0 * 2.0), height: 1000.0) - ) + + var titleSize: CGSize? + if let title = component.title { + titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: min(300.0, availableSize.width - 16.0 * 2.0), height: 1000.0) + ) + } let textSize = self.text.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( @@ -203,19 +216,80 @@ public final class EmptyStateIndicatorComponent: Component { } } + var additionalSeparatorTextSize: CGSize? + if let additionalActionSeparator = component.additionalActionSeparator { + let additionalSeparatorText: ComponentView + if let current = self.additionalSeparatorText { + additionalSeparatorText = current + } else { + additionalSeparatorText = ComponentView() + self.additionalSeparatorText = additionalSeparatorText + } + + let additionalSeparatorLeft: SimpleLayer + if let current = self.additionalSeparatorLeft { + additionalSeparatorLeft = current + } else { + additionalSeparatorLeft = SimpleLayer() + self.additionalSeparatorLeft = additionalSeparatorLeft + self.layer.addSublayer(additionalSeparatorLeft) + } + + let additionalSeparatorRight: SimpleLayer + if let current = self.additionalSeparatorRight { + additionalSeparatorRight = current + } else { + additionalSeparatorRight = SimpleLayer() + self.additionalSeparatorRight = additionalSeparatorRight + self.layer.addSublayer(additionalSeparatorRight) + } + + additionalSeparatorLeft.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + additionalSeparatorRight.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + + additionalSeparatorTextSize = additionalSeparatorText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: additionalActionSeparator, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: min(300.0, availableSize.width - 16.0 * 2.0), height: 100.0) + ) + } else { + if let additionalSeparatorLeft = self.additionalSeparatorLeft { + self.additionalSeparatorLeft = nil + additionalSeparatorLeft.removeFromSuperlayer() + } + if let additionalSeparatorRight = self.additionalSeparatorRight { + self.additionalSeparatorRight = nil + additionalSeparatorRight.removeFromSuperlayer() + } + if let additionalSeparatorText = self.additionalSeparatorText { + self.additionalSeparatorText = nil + additionalSeparatorText.view?.removeFromSuperview() + } + } + let animationSpacing: CGFloat = 11.0 let titleSpacing: CGFloat = 17.0 let buttonSpacing: CGFloat = 21.0 + let additionalSeparatorHeight: CGFloat = 31.0 var totalHeight: CGFloat = 0.0 if let animationSize { totalHeight += animationSize.height + animationSpacing } - totalHeight += titleSize.height + titleSpacing + textSize.height + if let titleSize { + totalHeight += titleSize.height + titleSpacing + } + totalHeight += textSize.height if let buttonSize { totalHeight += buttonSpacing + buttonSize.height } + if let _ = additionalSeparatorTextSize { + totalHeight += additionalSeparatorHeight + } if let additionalButtonSize { totalHeight += buttonSpacing + additionalButtonSize.height } @@ -234,7 +308,7 @@ public final class EmptyStateIndicatorComponent: Component { transition.setFrame(view: animationView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - animationSize.width) * 0.5), y: contentY), size: animationSize)) contentY += animationSize.height + animationSpacing } - if let titleView = self.title.view { + if let titleSize, let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } @@ -255,6 +329,25 @@ public final class EmptyStateIndicatorComponent: Component { transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - buttonSize.width) * 0.5), y: contentY), size: buttonSize)) contentY += buttonSize.height + buttonSpacing } + + if let additionalSeparatorTextSize, let additionalSeparatorText = self.additionalSeparatorText, let additionalSeparatorLeft = self.additionalSeparatorLeft, let additionalSeparatorRight = self.additionalSeparatorRight { + let additionalSeparatorTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - additionalSeparatorTextSize.width) * 0.5), y: contentY), size: additionalSeparatorTextSize) + if let additionalSeparatorTextView = additionalSeparatorText.view { + if additionalSeparatorTextView.superview == nil { + self.addSubview(additionalSeparatorTextView) + } + transition.setFrame(view: additionalSeparatorTextView, frame: additionalSeparatorTextFrame) + } + + let separatorWidth: CGFloat = 72.0 + let separatorSpacing: CGFloat = 10.0 + + transition.setFrame(layer: additionalSeparatorLeft, frame: CGRect(origin: CGPoint(x: additionalSeparatorTextFrame.minX - separatorSpacing - separatorWidth, y: additionalSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel))) + transition.setFrame(layer: additionalSeparatorRight, frame: CGRect(origin: CGPoint(x: additionalSeparatorTextFrame.maxX + separatorSpacing, y: additionalSeparatorTextFrame.midY + 1.0), size: CGSize(width: separatorWidth, height: UIScreenPixel))) + + contentY += additionalSeparatorHeight + } + if let additionalButtonSize, let additionalButtonView = self.additionalButton?.view { if additionalButtonView.superview == nil { self.addSubview(additionalButtonView) diff --git a/submodules/TelegramUI/Components/MiniAppListScreen/BUILD b/submodules/TelegramUI/Components/MiniAppListScreen/BUILD new file mode 100644 index 0000000000..02745a260c --- /dev/null +++ b/submodules/TelegramUI/Components/MiniAppListScreen/BUILD @@ -0,0 +1,39 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MiniAppListScreen", + module_name = "MiniAppListScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/AsyncDisplayKit", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/TelegramPresentationData", + "//submodules/TelegramUIPreferences", + "//submodules/PresentationDataUtils", + "//submodules/AccountContext", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/MergeLists", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/ItemListUI", + "//submodules/ChatListUI", + "//submodules/ItemListPeerItem", + "//submodules/TelegramUI/Components/ChatListHeaderComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/SearchBarNode", + "//submodules/Components/BalancedTextComponent", + "//submodules/ChatListSearchItemHeader", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift b/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift new file mode 100644 index 0000000000..08ecaa53f2 --- /dev/null +++ b/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift @@ -0,0 +1,811 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MergeLists +import ComponentDisplayAdapters +import ItemListPeerItem +import ItemListUI +import ChatListHeaderComponent +import PlainButtonComponent +import MultilineTextComponent +import SearchBarNode +import BalancedTextComponent +import ChatListSearchItemHeader + +final class MiniAppListScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let initialData: MiniAppListScreen.InitialData + + init( + context: AccountContext, + initialData: MiniAppListScreen.InitialData + ) { + self.context = context + self.initialData = initialData + } + + static func ==(lhs: MiniAppListScreenComponent, rhs: MiniAppListScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + + return true + } + + private enum ContentEntry: Comparable, Identifiable { + enum Id: Hashable { + case item(EnginePeer.Id) + } + + var stableId: Id { + switch self { + case let .item(peer, _): + return .item(peer.id) + } + } + + case item(peer: EnginePeer, sortIndex: Int) + + static func <(lhs: ContentEntry, rhs: ContentEntry) -> Bool { + switch lhs { + case let .item(lhsPeer, lhsSortIndex): + switch rhs { + case let .item(rhsPeer, rhsSortIndex): + if lhsSortIndex != rhsSortIndex { + return lhsSortIndex < rhsSortIndex + } + return lhsPeer.id < rhsPeer.id + } + } + } + + func item(listNode: ContentListNode) -> ListViewItem { + switch self { + case let .item(peer, _): + let text: ItemListPeerItemText + if case let .user(user) = peer, let subscriberCount = user.subscriberCount { + text = .text(listNode.presentationData.strings.Conversation_StatusBotSubscribers(subscriberCount), .secondary) + } else { + text = .none + } + + return ItemListPeerItem( + presentationData: ItemListPresentationData(listNode.presentationData), + dateTimeFormat: listNode.presentationData.dateTimeFormat, + nameDisplayOrder: listNode.presentationData.nameDisplayOrder, + context: listNode.context, + peer: peer, + presence: nil, + text: text, + label: .none, + editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), + enabled: true, + selectable: true, + sectionId: 0, + action: { [weak listNode] in + guard let listNode else { + return + } + if let view = listNode.parentView { + view.openItem(peer: peer) + } + }, + setPeerIdWithRevealedOptions: { _, _ in + }, + removePeer: { _ in + }, + noInsets: true, + header: nil + ) + } + } + } + + private final class ContentListNode: ListView { + weak var parentView: View? + let context: AccountContext + var presentationData: PresentationData + private var currentEntries: [ContentEntry] = [] + private var originalEntries: [ContentEntry] = [] + + init(parentView: View, context: AccountContext) { + self.parentView = parentView + self.context = context + self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + + super.init() + } + + func update(size: CGSize, insets: UIEdgeInsets, transition: ComponentTransition) { + let (listViewDuration, listViewCurve) = listViewAnimationDurationAndCurve(transition: transition.containedViewLayoutTransition) + self.transaction( + deleteIndices: [], + insertIndicesAndItems: [], + updateIndicesAndItems: [], + options: [.Synchronous, .LowLatency, .PreferSynchronousResourceLoading], + additionalScrollDistance: 0.0, + updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: listViewDuration, curve: listViewCurve), + updateOpaqueState: nil + ) + } + + func setEntries(entries: [ContentEntry], animated: Bool) { + self.originalEntries = entries + + let entries = entries + + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.currentEntries, rightList: entries) + self.currentEntries = entries + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } + + var options: ListViewDeleteAndInsertOptions = [.Synchronous, .LowLatency] + if animated { + options.insert(.AnimateInsertion) + } else { + options.insert(.PreferSynchronousResourceLoading) + } + + self.transaction( + deleteIndices: deletions, + insertIndicesAndItems: insertions, + updateIndicesAndItems: updates, + options: options, + scrollToItem: nil, + stationaryItemRange: nil, + updateOpaqueState: nil, + completion: { _ in + } + ) + } + } + + final class View: UIView { + private var contentListNode: ContentListNode? + private var ignoreVisibleContentOffsetChanged: Bool = false + private var emptySearchState: ComponentView? + + private let navigationBarView = ComponentView() + private var navigationHeight: CGFloat? + + private let sectionHeader = ComponentView() + + private var searchBarNode: SearchBarNode? + + private var isUpdating: Bool = false + + private var component: MiniAppListScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var recommendedAppPeers: [EnginePeer]? + private var recommendedAppPeersDisposable: Disposable? + private var keepUpdatedDisposable: Disposable? + + private var isSearchDisplayControllerActive: Bool = false + private var searchQuery: String = "" + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.recommendedAppPeersDisposable?.dispose() + self.keepUpdatedDisposable?.dispose() + } + + func scrollToTop() { + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func openItem(peer: EnginePeer) { + guard let component = self.component else { + return + } + guard let environment = self.environment, let controller = environment.controller() else { + return + } + + if let peerInfoScreen = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { + peerInfoScreen.navigationPresentation = .modal + controller.push(peerInfoScreen) + } + } + + private func updateNavigationBar( + component: MiniAppListScreenComponent, + theme: PresentationTheme, + strings: PresentationStrings, + size: CGSize, + insets: UIEdgeInsets, + statusBarHeight: CGFloat, + isModal: Bool, + transition: ComponentTransition, + deferScrollApplication: Bool + ) -> CGFloat { + let rightButtons: [AnyComponentWithIdentity] = [] + + //TODO:localize + let titleText: String = "Examples" + + let closeTitle: String = strings.Common_Close + let headerContent: ChatListHeaderComponent.Content? = ChatListHeaderComponent.Content( + title: titleText, + navigationBackTitle: nil, + titleComponent: nil, + chatListTitle: nil, + leftButton: isModal ? AnyComponentWithIdentity(id: "close", component: AnyComponent(NavigationButtonComponent( + content: .text(title: closeTitle, isBold: false), + pressed: { [weak self] _ in + guard let self else { + return + } + if self.attemptNavigation(complete: {}) { + self.environment?.controller()?.dismiss() + } + } + ))) : nil, + rightButtons: rightButtons, + backTitle: isModal ? nil : strings.Common_Back, + backPressed: { [weak self] in + guard let self else { + return + } + + if self.attemptNavigation(complete: {}) { + self.environment?.controller()?.dismiss() + } + } + ) + + let navigationBarSize = self.navigationBarView.update( + transition: transition, + component: AnyComponent(ChatListNavigationBar( + context: component.context, + theme: theme, + strings: strings, + statusBarHeight: statusBarHeight, + sideInset: insets.left, + isSearchActive: self.isSearchDisplayControllerActive, + isSearchEnabled: true, + primaryContent: headerContent, + secondaryContent: nil, + secondaryTransition: 0.0, + storySubscriptions: nil, + storiesIncludeHidden: false, + uploadProgress: [:], + tabsNode: nil, + tabsNodeIsSearch: false, + accessoryPanelContainer: nil, + accessoryPanelContainerHeight: 0.0, + activateSearch: { [weak self] _ in + guard let self else { + return + } + + self.isSearchDisplayControllerActive = true + self.state?.updated(transition: .spring(duration: 0.4)) + }, + openStatusSetup: { _ in + }, + allowAutomaticOrder: { + } + )), + environment: {}, + containerSize: size + ) + + //TODO:localize + let sectionHeaderSize = self.sectionHeader.update( + transition: transition, + component: AnyComponent(ListHeaderComponent( + theme: theme, + title: "APPS THAT ACCEPT STARS" + )), + environment: {}, + containerSize: CGSize(width: size.width, height: 1000.0) + ) + if let sectionHeaderView = self.sectionHeader.view { + if sectionHeaderView.superview == nil { + sectionHeaderView.layer.anchorPoint = CGPoint() + self.addSubview(sectionHeaderView) + } + transition.setBounds(view: sectionHeaderView, bounds: CGRect(origin: CGPoint(), size: sectionHeaderSize)) + } + + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + if deferScrollApplication { + navigationBarComponentView.deferScrollApplication = true + } + + if navigationBarComponentView.superview == nil { + self.addSubview(navigationBarComponentView) + } + transition.setFrame(view: navigationBarComponentView, frame: CGRect(origin: CGPoint(), size: navigationBarSize)) + + return navigationBarSize.height + } else { + return 0.0 + } + } + + private func updateNavigationScrolling(navigationHeight: CGFloat, transition: ComponentTransition) { + var mainOffset: CGFloat + if let recommendedAppPeers = self.recommendedAppPeers, !recommendedAppPeers.isEmpty { + if let contentListNode = self.contentListNode { + switch contentListNode.visibleContentOffset() { + case .none: + mainOffset = 0.0 + case .unknown: + mainOffset = navigationHeight + case let .known(value): + mainOffset = value + } + } else { + mainOffset = navigationHeight + } + } else { + mainOffset = navigationHeight + } + + mainOffset = min(mainOffset, ChatListNavigationBar.searchScrollHeight) + if abs(mainOffset) < 0.1 { + mainOffset = 0.0 + } + + let resultingOffset = mainOffset + + var offset = resultingOffset + if self.isSearchDisplayControllerActive { + offset = 0.0 + } + + if let sectionHeaderView = self.sectionHeader.view { + transition.setPosition(view: sectionHeaderView, position: CGPoint(x: 0.0, y: navigationHeight - offset)) + } + + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + navigationBarComponentView.applyScroll(offset: offset, allowAvatarsExpansion: false, forceUpdate: false, transition: transition.withUserData(ChatListNavigationBar.AnimationHint( + disableStoriesAnimations: false, + crossfadeStoryPeers: false + ))) + } + } + + func update(component: MiniAppListScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + if self.component == nil { + self.recommendedAppPeers = component.initialData.recommendedAppPeers + + /*self.shortcutMessageListDisposable = (component.context.engine.accountData.shortcutMessageList(onlyRemote: false) + |> deliverOnMainQueue).startStrict(next: { [weak self] shortcutMessageList in + guard let self else { + return + } + self.shortcutMessageList = shortcutMessageList + if !self.isUpdating { + self.state?.updated(transition: .immediate) + } + })*/ + + self.keepUpdatedDisposable = component.context.engine.peers.requestRecommendedAppsIfNeeded().startStrict() + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + if themeUpdated { + self.backgroundColor = environment.theme.list.plainBackgroundColor + } + + var isModal = false + if let controller = environment.controller(), controller.navigationPresentation == .modal { + isModal = true + } + + var statusBarHeight = environment.statusBarHeight + if isModal { + statusBarHeight = max(statusBarHeight, 1.0) + } + + let listBottomInset = environment.safeInsets.bottom + environment.additionalInsets.bottom + let navigationHeight = self.updateNavigationBar( + component: component, + theme: environment.theme, + strings: environment.strings, + size: availableSize, + insets: environment.safeInsets, + statusBarHeight: statusBarHeight, + isModal: isModal, + transition: transition, + deferScrollApplication: true + ) + self.navigationHeight = navigationHeight + + var removedSearchBar: SearchBarNode? + if self.isSearchDisplayControllerActive { + let searchBarNode: SearchBarNode + var searchBarTransition = transition + if let current = self.searchBarNode { + searchBarNode = current + } else { + searchBarTransition = .immediate + let searchBarTheme = SearchBarNodeTheme(theme: environment.theme, hasSeparator: false) + searchBarNode = SearchBarNode( + theme: searchBarTheme, + strings: environment.strings, + fieldStyle: .modern, + displayBackground: false + ) + searchBarNode.placeholderString = NSAttributedString(string: environment.strings.Common_Search, font: Font.regular(17.0), textColor: searchBarTheme.placeholder) + self.searchBarNode = searchBarNode + searchBarNode.cancel = { [weak self] in + guard let self else { + return + } + self.isSearchDisplayControllerActive = false + self.state?.updated(transition: .spring(duration: 0.4)) + } + searchBarNode.textUpdated = { [weak self] query, _ in + guard let self else { + return + } + if self.searchQuery != query { + self.searchQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + self.state?.updated(transition: .immediate) + } + } + DispatchQueue.main.async { [weak self, weak searchBarNode] in + guard let self, let searchBarNode, self.searchBarNode === searchBarNode else { + return + } + searchBarNode.activate() + } + } + + var searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight - 54.0 + 2.0), size: CGSize(width: availableSize.width, height: 54.0)) + if isModal { + searchBarFrame.origin.y += 2.0 + } + searchBarNode.updateLayout(boundingSize: searchBarFrame.size, leftInset: environment.safeInsets.left + 6.0, rightInset: environment.safeInsets.right, transition: searchBarTransition.containedViewLayoutTransition) + searchBarTransition.setFrame(view: searchBarNode.view, frame: searchBarFrame) + if searchBarNode.view.superview == nil { + self.addSubview(searchBarNode.view) + + if case let .curve(duration, curve) = transition.animation, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode = navigationBarView.searchContentNode?.placeholderNode { + let timingFunction: String + switch curve { + case .easeInOut: + timingFunction = CAMediaTimingFunctionName.easeOut.rawValue + case .linear: + timingFunction = CAMediaTimingFunctionName.linear.rawValue + case .spring: + timingFunction = kCAMediaTimingFunctionSpring + case .custom: + timingFunction = kCAMediaTimingFunctionSpring + } + + searchBarNode.animateIn(from: placeholderNode, duration: duration, timingFunction: timingFunction) + } + } + } else { + self.searchQuery = "" + if let searchBarNode = self.searchBarNode { + self.searchBarNode = nil + removedSearchBar = searchBarNode + } + } + + let contentListNode: ContentListNode + if let current = self.contentListNode { + contentListNode = current + } else { + contentListNode = ContentListNode(parentView: self, context: component.context) + self.contentListNode = contentListNode + + contentListNode.visibleContentOffsetChanged = { [weak self] offset in + guard let self else { + return + } + guard let navigationHeight = self.navigationHeight else { + return + } + if self.ignoreVisibleContentOffsetChanged { + return + } + self.updateNavigationScrolling(navigationHeight: navigationHeight, transition: .immediate) + } + + if let sectionHeaderView = self.sectionHeader.view { + self.insertSubview(contentListNode.view, belowSubview: sectionHeaderView) + } else if let navigationBarComponentView = self.navigationBarView.view { + self.insertSubview(contentListNode.view, belowSubview: navigationBarComponentView) + } else { + self.addSubview(contentListNode.view) + } + } + + var contentTopInset = navigationHeight + if let sectionHeaderView = self.sectionHeader.view { + contentTopInset += sectionHeaderView.bounds.height + } + transition.setFrame(view: contentListNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + self.ignoreVisibleContentOffsetChanged = true + contentListNode.update(size: availableSize, insets: UIEdgeInsets(top: contentTopInset, left: environment.safeInsets.left, bottom: listBottomInset, right: environment.safeInsets.right), transition: transition) + self.ignoreVisibleContentOffsetChanged = false + + var entries: [ContentEntry] = [] + if let recommendedAppPeers = self.recommendedAppPeers { + let normalizedSearchQuery = self.searchQuery.lowercased().trimmingTrailingSpaces() + for peer in recommendedAppPeers { + if !self.searchQuery.isEmpty { + var matches = false + if peer.indexName.matchesByTokens(normalizedSearchQuery) { + matches = true + } + if !matches { + continue + } + } + entries.append(.item(peer: peer, sortIndex: entries.count)) + } + } + contentListNode.setEntries(entries: entries, animated: !transition.animation.isImmediate) + if let sectionHeaderView = self.sectionHeader.view { + sectionHeaderView.isHidden = entries.isEmpty + } + + if !self.searchQuery.isEmpty && entries.isEmpty { + var emptySearchStateTransition = transition + let emptySearchState: ComponentView + if let current = self.emptySearchState { + emptySearchState = current + } else { + emptySearchStateTransition = emptySearchStateTransition.withAnimation(.none) + emptySearchState = ComponentView() + self.emptySearchState = emptySearchState + } + let emptySearchStateSize = emptySearchState.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Conversation_SearchNoResults, font: Font.regular(17.0), textColor: environment.theme.list.freeTextColor, paragraphAlignment: .center)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: availableSize.height) + ) + var emptySearchStateBottomInset = listBottomInset + emptySearchStateBottomInset = max(emptySearchStateBottomInset, environment.inputHeight) + let emptySearchStateFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - emptySearchStateSize.width) * 0.5), y: navigationHeight + floor((availableSize.height - emptySearchStateBottomInset - navigationHeight) * 0.5)), size: emptySearchStateSize) + if let emptySearchStateView = emptySearchState.view { + if emptySearchStateView.superview == nil { + if let navigationBarComponentView = self.navigationBarView.view { + self.insertSubview(emptySearchStateView, belowSubview: navigationBarComponentView) + } else { + self.addSubview(emptySearchStateView) + } + } + emptySearchStateTransition.containedViewLayoutTransition.updatePosition(layer: emptySearchStateView.layer, position: emptySearchStateFrame.center) + emptySearchStateView.bounds = CGRect(origin: CGPoint(), size: emptySearchStateFrame.size) + } + } else if let emptySearchState = self.emptySearchState { + self.emptySearchState = nil + emptySearchState.view?.removeFromSuperview() + } + + if let recommendedAppPeers = self.recommendedAppPeers, !recommendedAppPeers.isEmpty { + contentListNode.isHidden = false + } else { + contentListNode.isHidden = true + } + + self.updateNavigationScrolling(navigationHeight: navigationHeight, transition: transition) + + if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { + navigationBarComponentView.deferScrollApplication = false + navigationBarComponentView.applyCurrentScroll(transition: transition) + } + + if let removedSearchBar { + if !transition.animation.isImmediate, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode = + navigationBarView.searchContentNode?.placeholderNode { + removedSearchBar.transitionOut(to: placeholderNode, transition: transition.containedViewLayoutTransition, completion: { [weak removedSearchBar] in + removedSearchBar?.view.removeFromSuperview() + }) + } else { + removedSearchBar.view.removeFromSuperview() + } + } + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class MiniAppListScreen: ViewControllerComponentContainer { + public final class InitialData: MiniAppListScreenInitialData { + let recommendedAppPeers: [EnginePeer] + + init( + recommendedAppPeers: [EnginePeer] + ) { + self.recommendedAppPeers = recommendedAppPeers + } + } + + private let context: AccountContext + + public init(context: AccountContext, initialData: InitialData) { + self.context = context + + super.init(context: context, component: MiniAppListScreenComponent( + context: context, + initialData: initialData + ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) + + self.navigationPresentation = .modal + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? MiniAppListScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? MiniAppListScreenComponent.View else { + return true + } + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } + + public static func initialData(context: AccountContext) -> Signal { + let recommendedAppPeers = context.engine.peers.recommendedAppPeerIds() + |> take(1) + |> mapToSignal { peerIds -> Signal<[EnginePeer], NoError> in + guard let peerIds else { + return .single([]) + } + return context.engine.data.get( + EngineDataList(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> map { peers -> [EnginePeer] in + return peers.compactMap { $0 } + } + } + + return recommendedAppPeers + |> map { recommendedAppPeers -> MiniAppListScreenInitialData in + return InitialData( + recommendedAppPeers: recommendedAppPeers + ) + } + } +} + +private final class ListHeaderComponent: Component { + let theme: PresentationTheme + let title: String + + init( + theme: PresentationTheme, + title: String + ) { + self.theme = theme + self.title = title + } + + static func ==(lhs: ListHeaderComponent, rhs: ListHeaderComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.title != rhs.title { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + + private var component: ListHeaderComponent? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ListHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + if self.component?.theme !== component.theme { + self.backgroundColor = component.theme.chatList.sectionHeaderFillColor + } + + let insets = UIEdgeInsets(top: 7.0, left: 16.0, bottom: 7.0, right: 16.0) + + let titleString = component.title + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleString, font: Font.regular(13.0), textColor: component.theme.chatList.sectionHeaderTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - insets.left - insets.right, height: 100.0) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + titleView.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: titleSize) + } + + return CGSize(width: availableSize.width, height: titleSize.height + insets.top + insets.bottom) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index 6dd583ca77..5ad6ea1af0 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -1148,7 +1148,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen var botPreviewStoryListContext: StoryListContext? let hasBotPreviewItems: Signal if case .bot = kind { - let botPreviewStoryListContextValue = BotPreviewStoryListContext(account: context.account, engine: context.engine, peerId: peerId) + let botPreviewStoryListContextValue = BotPreviewStoryListContext(account: context.account, engine: context.engine, peerId: peerId, language: nil, assumeEmpty: false) botPreviewStoryListContext = botPreviewStoryListContextValue hasBotPreviewItems = botPreviewStoryListContextValue.state |> map { state in @@ -1307,7 +1307,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen if let user = peerView.peers[peerView.peerId] as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp), botInfo.flags.contains(.canEdit) { availablePanes?.insert(.botPreview, at: 0) - } else if let cachedData = peerView.cachedData as? CachedUserData, let botPreview = cachedData.botPreview, !botPreview.media.isEmpty { + } else if let cachedData = peerView.cachedData as? CachedUserData, let botPreview = cachedData.botPreview, !botPreview.items.isEmpty { availablePanes?.insert(.botPreview, at: 0) } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 9e5030d259..5f768653d3 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -3287,7 +3287,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, navigateToMessage: { _, _, _ in }, navigateToMessageStandalone: { _ in }, navigateToThreadMessage: { _, _, _ in - }, tapMessage: nil, clickThroughMessage: { + }, tapMessage: nil, clickThroughMessage: { _, _ in }, toggleMessagesSelection: { [weak self] ids, value in guard let strongSelf = self else { return @@ -9864,7 +9864,12 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro guard let self else { return } - self.openBotPreviewEditor(target: .botPreview(self.peerId), source: result, transitionIn: (transitionView, transitionRect, transitionImage)) + + guard let pane = self.paneContainerNode.currentPane?.node as? PeerInfoStoryPaneNode else { + return + } + + self.openBotPreviewEditor(target: .botPreview(id: self.peerId, language: pane.currentBotPreviewLanguage?.id), source: result, transitionIn: (transitionView, transitionRect, transitionImage)) }, dismissed: {}, groupsPresented: {} @@ -10939,6 +10944,23 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro } }))) + if let language = pane.currentBotPreviewLanguage { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Delete \(language.name)", textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak pane] _, a in + if ignoreNextActions { + return + } + ignoreNextActions = true + a(.default) + + if let pane { + pane.presentDeleteBotPreviewLanguage() + } + }))) + } + let contextController = ContextController(presentationData: self.presentationData, source: .reference(PeerInfoContextReferenceContentSource(controller: controller, sourceNode: source)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) contextController.passthroughTouchEvent = { [weak self] sourceView, point in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD index aa61d9591a..1bef45d94e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD @@ -46,6 +46,8 @@ swift_library( "//submodules/TelegramUI/Components/MediaEditorScreen", "//submodules/LocationUI", "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/TabSelectorComponent", + "//submodules/TelegramUI/Components/Settings/LanguageSelectionScreen", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index ba14a31620..4ab6017f5b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -41,6 +41,8 @@ import Geocoding import ItemListUI import MultilineTextComponent import LocationUI +import TabSelectorComponent +import LanguageSelectionScreen private let mediaBadgeBackgroundColor = UIColor(white: 0.0, alpha: 0.6) private let mediaBadgeTextColor = UIColor.white @@ -1565,6 +1567,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private var mapInfoNode: LocationInfoListItemNode? private var searchHeader: ComponentView? + private var botPreviewLanguageTab: ComponentView? + private var botPreviewFooter: ComponentView? + private var barBackgroundLayer: SimpleLayer? private let itemGrid: SparseItemGrid @@ -1638,7 +1643,11 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? public var tabBarOffset: CGFloat { - return self.itemGrid.coveringInsetOffset + if case .botPreview = self.scope { + return 0.0 + } else { + return self.itemGrid.coveringInsetOffset + } } private var currentListState: StoryListContext.State? @@ -1646,6 +1655,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private var hiddenMediaDisposable: Disposable? private let updateDisposable = MetaDisposable() + private var currentBotPreviewLanguages: [StoryListContext.State.Language] = [] + private var removedBotPreviewLanguages = Set() + private var numberOfItemsToRequest: Int = 50 private var isRequestingView: Bool = false private var isFirstHistoryView: Bool = true @@ -1656,6 +1668,24 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public private(set) var calendarSource: SparseMessageCalendar? private var listSource: StoryListContext + + private let maxBotPreviewCount: Int + + private let defaultListSource: StoryListContext + private var cachedListSources: [String: StoryListContext] = [:] + + public var currentBotPreviewLanguage: (id: String, name: String)? { + guard let listSource = self.listSource as? BotPreviewStoryListContext else { + return nil + } + guard let id = listSource.language else { + return nil + } + guard let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) else { + return nil + } + return (language.id, language.name) + } public var openCurrentDate: (() -> Void)? public var paneDidScroll: (() -> Void)? @@ -1723,11 +1753,18 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr case let .location(coordinates, venue): self.listSource = SearchStoryListContext(account: context.account, source: .mediaArea(.venue(coordinates: coordinates, venue: venue))) case let .botPreview(id): - self.listSource = BotPreviewStoryListContext(account: context.account, engine: context.engine, peerId: id) + self.listSource = BotPreviewStoryListContext(account: context.account, engine: context.engine, peerId: id, language: nil, assumeEmpty: false) } } + self.defaultListSource = self.listSource self.calendarSource = nil + var maxBotPreviewCount = 10 + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["bot_preview_medias_max"] as? Double { + maxBotPreviewCount = Int(value) + } + self.maxBotPreviewCount = maxBotPreviewCount + super.init() if case .peer = self.scope { @@ -2689,6 +2726,10 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.listDisposable?.dispose() self.listDisposable = nil + + if reloadAtTop { + self.didUpdateItemsOnce = false + } self.listDisposable = (state |> deliverOn(queue)).startStrict(next: { [weak self] state in @@ -2700,7 +2741,11 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if state.totalCount == 0 { if case .botPreview = self.scope { //TODO:localize - title = "no preview added" + if state.isLoading { + title = "loading" + } else { + title = "no preview added" + } } else { title = "" } @@ -2738,9 +2783,24 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr return } + var botPreviewLanguages = self.currentBotPreviewLanguages + for language in state.availableLanguages { + if !botPreviewLanguages.contains(where: { $0.id == language.id }) && !self.removedBotPreviewLanguages.contains(language.id) { + botPreviewLanguages.append(language) + } + } + botPreviewLanguages.sort(by: { $0.name < $1.name }) self.currentListState = state self.updateItemsFromState(state: state, firstTime: firstTime, reloadAtTop: reloadAtTop, synchronous: synchronous, animated: false) + + if self.currentBotPreviewLanguages != botPreviewLanguages || reloadAtTop { + self.currentBotPreviewLanguages = botPreviewLanguages + if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { + self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: synchronous, transition: .immediate) + } + } + firstTime = false self.isRequestingView = false } @@ -2853,7 +2913,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { var gridSnapshot: UIView? - if reloadAtTop { + if case .botPreview = scope { + } else if reloadAtTop { gridSnapshot = self.itemGrid.view.snapshotView(afterScreenUpdates: false) } self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: transition, animateGridItems: animated) @@ -2971,43 +3032,6 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil - - /*var foundItemLayer: SparseItemGridLayer? - self.itemGrid.forEachVisibleItem { item in - guard let itemLayer = item.layer as? ItemLayer else { - return - } - if let item = itemLayer.item, item.message.id == messageId { - foundItemLayer = itemLayer - } - } - if let itemLayer = foundItemLayer { - let itemFrame = self.view.convert(self.itemGrid.frameForItem(layer: itemLayer), from: self.itemGrid.view) - let proxyNode = ASDisplayNode() - proxyNode.frame = itemFrame - if let contents = itemLayer.getContents() { - if let image = contents as? UIImage { - proxyNode.contents = image.cgImage - } else { - proxyNode.contents = contents - } - } - proxyNode.isHidden = true - self.addSubnode(proxyNode) - - let escapeNotification = EscapeNotification { - proxyNode.removeFromSupernode() - } - - return (proxyNode, proxyNode.bounds, { - let view = UIView() - view.frame = proxyNode.frame - view.layer.contents = proxyNode.layer.contents - escapeNotification.keep() - return (view, nil) - }) - } - return nil*/ } public func extractPendingStoryTransitionView() -> UIView? { @@ -3294,29 +3318,29 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr controller?.dismissAnimated() } - var mappedItemIds: [MediaId] = [] + var mappedMedia: [Media] = [] if let items = self.items { - mappedItemIds = items.items.compactMap { item -> MediaId? in + mappedMedia = items.items.compactMap { item -> Media? in guard let item = item as? VisualMediaItem else { return nil } if ids.contains(item.story.id) { - return item.story.media.id + return item.story.media._asMedia() } else { return nil } } } - if mappedItemIds.isEmpty { + if mappedMedia.isEmpty { return } //TODO:localize let title: String - if mappedItemIds.count == 1 { + if mappedMedia.count == 1 { title = "Delete 1 Preview?" } else { - title = "Delete \(mappedItemIds.count) Previews?" + title = "Delete \(mappedMedia.count) Previews?" } controller.setItemGroups([ @@ -3328,12 +3352,15 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr guard let self else { return } + guard let listSource = self.listSource as? BotPreviewStoryListContext else { + return + } if let parentController = self.parentController as? PeerInfoScreen { parentController.cancelItemSelection() } - let _ = self.context.engine.messages.deleteBotPreviews(peerId: peerId, ids: mappedItemIds).startStandalone() + let _ = self.context.engine.messages.deleteBotPreviews(peerId: peerId, language: listSource.language, media: mappedMedia).startStandalone() }) ]), ActionSheetItemGroup(items: [ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, action: { dismissAction() })]) @@ -3349,8 +3376,14 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } private func gridScrollingOffsetUpdated(transition: ContainedViewLayoutTransition) { - if let _ = self.mapNode, let currentParams = self.currentParams { - self.updateMapLayout(size: currentParams.size, topInset: currentParams.topInset, bottomInset: currentParams.bottomInset, deviceMetrics: currentParams.deviceMetrics, transition: transition) + if let currentParams = self.currentParams { + if let _ = self.mapNode { + self.updateMapLayout(size: currentParams.size, topInset: currentParams.topInset, bottomInset: currentParams.bottomInset, deviceMetrics: currentParams.deviceMetrics, transition: transition) + } + if case .botPreview = self.scope, self.canManageStories { + self.updateBotPreviewLanguageTab(size: currentParams.size, topInset: currentParams.topInset, transition: transition) + self.updateBotPreviewFooter(size: currentParams.size, bottomInset: currentParams.bottomInset, transition: transition) + } } } @@ -3504,6 +3537,150 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } + private func updateBotPreviewLanguageTab(size: CGSize, topInset: CGFloat, transition: ContainedViewLayoutTransition) { + guard case .botPreview = self.scope, self.canManageStories else { + return + } + + let botPreviewLanguageTab: ComponentView + if let current = self.botPreviewLanguageTab { + botPreviewLanguageTab = current + } else { + botPreviewLanguageTab = ComponentView() + self.botPreviewLanguageTab = botPreviewLanguageTab + } + + //TODO:localize + var languageItems: [TabSelectorComponent.Item] = [] + languageItems.append(TabSelectorComponent.Item( + id: AnyHashable("_main"), + title: "Main" + )) + for language in self.currentBotPreviewLanguages { + languageItems.append(TabSelectorComponent.Item( + id: AnyHashable(language.id), + title: language.name + )) + } + languageItems.append(TabSelectorComponent.Item( + id: AnyHashable("_add"), + title: "+ Add Language" + )) + var selectedLanguageId = "_main" + if let listSource = self.listSource as? BotPreviewStoryListContext, let language = listSource.language { + selectedLanguageId = language + } + + let botPreviewLanguageTabSize = botPreviewLanguageTab.update( + transition: ComponentTransition(transition), + component: AnyComponent(TabSelectorComponent( + colors: TabSelectorComponent.Colors( + foreground: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8), + selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05) + ), + customLayout: TabSelectorComponent.CustomLayout( + font: Font.medium(14.0), + spacing: 9.0, + verticalInset: 11.0 + ), + items: languageItems, + selectedId: AnyHashable(selectedLanguageId), + setSelectedId: { [weak self] id in + guard let self, let id = id.base as? String else { + return + } + if id == "_add" { + self.presentAddBotPreviewLanguage() + } else if id == "_main" { + self.setBotPreviewLanguage(id: nil, assumeEmpty: false) + } else if let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) { + self.setBotPreviewLanguage(id: language.id, assumeEmpty: false) + } + } + )), + environment: {}, + containerSize: CGSize(width: size.width, height: 44.0) + ) + var botPreviewLanguageTabFrame = CGRect(origin: CGPoint(x: floor((size.width - botPreviewLanguageTabSize.width) * 0.5), y: topInset - 11.0), size: botPreviewLanguageTabSize) + + let effectiveScrollingOffset: CGFloat + effectiveScrollingOffset = self.itemGrid.scrollingOffset + botPreviewLanguageTabFrame.origin.y -= effectiveScrollingOffset + + if let botPreviewLanguageTabView = botPreviewLanguageTab.view { + if botPreviewLanguageTabView.superview == nil { + self.view.addSubview(botPreviewLanguageTabView) + } + transition.updateFrame(view: botPreviewLanguageTabView, frame: botPreviewLanguageTabFrame) + } + } + + private func updateBotPreviewFooter(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) { + if let items = self.items, !items.items.isEmpty { + var botPreviewFooterTransition = ComponentTransition(transition) + let botPreviewFooter: ComponentView + if let current = self.botPreviewFooter { + botPreviewFooter = current + } else { + botPreviewFooterTransition = .immediate + botPreviewFooter = ComponentView() + self.botPreviewFooter = botPreviewFooter + } + + var isMainLanguage = true + let text: String + if let listSource = self.listSource as? BotPreviewStoryListContext, let id = listSource.language, let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) { + isMainLanguage = false + text = "This preview will be displayed for all users who have \(language.name) set as their language." + } else { + text = "This preview will be shown by default. You can also add translations into specific languages." + } + + let botPreviewFooterSize = botPreviewFooter.update( + transition: botPreviewFooterTransition, + component: AnyComponent(EmptyStateIndicatorComponent( + context: self.context, + theme: self.presentationData.theme, + fitToHeight: true, + animationName: nil, + title: nil, + text: text, + actionTitle: "Add Preview", + action: { [weak self] in + guard let self else { + return + } + self.emptyAction?() + }, + additionalActionTitle: isMainLanguage ? "Create a Translation" : nil, + additionalAction: { [weak self] in + guard let self else { + return + } + if isMainLanguage { + self.presentAddBotPreviewLanguage() + } + }, + additionalActionSeparator: isMainLanguage ? "or" : nil + )), + environment: {}, + containerSize: CGSize(width: size.width, height: 1000.0) + ) + let botPreviewFooterFrame = CGRect(origin: CGPoint(x: floor((size.width - botPreviewFooterSize.width) * 0.5), y: self.itemGrid.contentBottomOffset - botPreviewFooterSize.height - bottomInset), size: botPreviewFooterSize) + if let botPreviewFooterView = botPreviewFooter.view { + if botPreviewFooterView.superview == nil { + self.view.addSubview(botPreviewFooterView) + } + botPreviewFooterTransition.setFrame(view: botPreviewFooterView, frame: botPreviewFooterFrame) + } + } else { + if let botPreviewFooter = self.botPreviewFooter { + self.botPreviewFooter = nil + botPreviewFooter.view?.removeFromSuperview() + } + } + } + public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: synchronous, transition: transition, animateGridItems: false) } @@ -3555,7 +3732,19 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr transition.updateFrame(layer: barBackgroundLayer, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: gridTopInset))) } + let defaultBottomInset = bottomInset var bottomInset = bottomInset + + if case .botPreview = self.scope, self.canManageStories { + updateBotPreviewLanguageTab(size: size, topInset: topInset, transition: transition) + gridTopInset += 50.0 + + updateBotPreviewFooter(size: size, bottomInset: defaultBottomInset, transition: transition) + if let botPreviewFooterView = self.botPreviewFooter?.view { + bottomInset += 18.0 + botPreviewFooterView.bounds.height + } + } + if self.isProfileEmbedded, let selectedIds = self.itemInteraction.selectedIds, self.canManageStories, case let .peer(peerId, _, isArchived) = self.scope { let selectionPanel: ComponentView var selectionPanelTransition = ComponentTransition(transition) @@ -3816,6 +4005,12 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.emptyStateView = emptyStateView } //TODO:localize + + var isMainLanguage = true + if let listSource = self.listSource as? BotPreviewStoryListContext, let _ = listSource.language { + isMainLanguage = false + } + let emptyStateSize = emptyStateView.update( transition: emptyStateTransition, component: AnyComponent(EmptyStateIndicatorComponent( @@ -3824,7 +4019,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr fitToHeight: self.isProfileEmbedded, animationName: nil, title: "No Preview", - text: "Upload screenshots and video demos for your Mini App that will be visible for your users here.", + text: "Upload up to \(self.maxBotPreviewCount) screenshots and video demos for your mini app.", actionTitle: self.canManageStories ? "Add Preview" : nil, action: { [weak self] in guard let self else { @@ -3832,8 +4027,15 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } self.emptyAction?() }, - additionalActionTitle: nil, - additionalAction: {} + additionalActionTitle: self.canManageStories ? (isMainLanguage ? "Create a Translation" : "Delete this Translation") : nil, + additionalAction: { + if isMainLanguage { + self.presentAddBotPreviewLanguage() + } else { + self.presentDeleteBotPreviewLanguage() + } + }, + additionalActionSeparator: self.canManageStories ? "or" : nil )), environment: {}, containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset) @@ -3860,7 +4062,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if self.isProfileEmbedded, case .botPreview = self.scope { backgroundColor = presentationData.theme.list.blocksBackgroundColor } else if self.isProfileEmbedded { - backgroundColor = presentationData.theme.list.plainBackgroundColor + backgroundColor = presentationData.theme.list.blocksBackgroundColor } else { backgroundColor = presentationData.theme.list.blocksBackgroundColor } @@ -3876,18 +4078,28 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.emptyStateView = nil if let emptyStateComponentView = emptyStateView.view { - subTransition.setAlpha(view: emptyStateComponentView, alpha: 0.0, completion: { [weak emptyStateComponentView] _ in - emptyStateComponentView?.removeFromSuperview() - }) + if self.didUpdateItemsOnce { + subTransition.setAlpha(view: emptyStateComponentView, alpha: 0.0, completion: { [weak emptyStateComponentView] _ in + emptyStateComponentView?.removeFromSuperview() + }) + } else { + emptyStateComponentView.removeFromSuperview() + } } - if self.isProfileEmbedded { + if self.isProfileEmbedded, case .botPreview = self.scope { + subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.blocksBackgroundColor) + } else if self.isProfileEmbedded { subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.plainBackgroundColor) } else { subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.blocksBackgroundColor) } } else { - self.view.backgroundColor = .clear + if self.isProfileEmbedded, case .botPreview = self.scope { + self.view.backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else { + self.view.backgroundColor = .clear + } } } @@ -3904,6 +4116,10 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.itemGrid.pinchEnabled = items.count > 2 self.itemGrid.update(size: size, insets: UIEdgeInsets(top: gridTopInset, left: sideInset, bottom: bottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none, transition: animateGridItems ? .spring(duration: 0.35) : .immediate) } + + if case .botPreview = self.scope, self.canManageStories { + updateBotPreviewFooter(size: size, bottomInset: defaultBottomInset, transition: transition) + } } public func currentTopTimestamp() -> Int32? { @@ -3992,12 +4208,109 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr return false } - var maxCount = 10 - if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["bot_preview_medias_max"] as? Double { - maxCount = Int(value) + return items.count < self.maxBotPreviewCount + } + + private func presentAddBotPreviewLanguage() { + self.parentController?.push(LanguageSelectionScreen(context: self.context, selectLocalization: { [weak self] info in + guard let self else { + return + } + self.addBotPreviewLanguage(language: StoryListContext.State.Language(id: info.languageCode, name: info.title)) + })) + } + + public func presentDeleteBotPreviewLanguage() { + //TODO:localize + self.parentController?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: "Delete Translation", text: "Are you sure you want to delete this translation?", actions: [ + TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_Cancel, action: { + }), + TextAlertAction(type: .destructiveAction, title: "OK", action: { [weak self] in + guard let self else { + return + } + if let listSource = self.listSource as? BotPreviewStoryListContext, let language = listSource.language { + self.deleteBotPreviewLanguage(id: language) + } + }) + ], parseMarkdown: true), in: .window(.root)) + } + + private func addBotPreviewLanguage(language: StoryListContext.State.Language) { + var botPreviewLanguages = self.currentBotPreviewLanguages + + var assumeEmpty = false + if !botPreviewLanguages.contains(where: { $0.id == language.id}) { + botPreviewLanguages.append(language) + assumeEmpty = true + } + botPreviewLanguages.sort(by: { $0.name < $1.name }) + self.removedBotPreviewLanguages.remove(language.id) + + if self.currentBotPreviewLanguages != botPreviewLanguages { + self.currentBotPreviewLanguages = botPreviewLanguages + if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { + self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate) + } } - return items.count < maxCount + self.setBotPreviewLanguage(id: language.id, assumeEmpty: assumeEmpty) + } + + private func deleteBotPreviewLanguage(id: String) { + var botPreviewLanguages = self.currentBotPreviewLanguages + + if let index = botPreviewLanguages.firstIndex(where: { $0.id == id}) { + botPreviewLanguages.remove(at: index) + } + self.removedBotPreviewLanguages.insert(id) + + guard case let .botPreview(peerId) = self.scope else { + return + } + + var mappedMedia: [Media] = [] + if let items = self.items { + mappedMedia = items.items.compactMap { item -> Media? in + guard let item = item as? VisualMediaItem else { + return nil + } + return item.story.media._asMedia() + } + } + let _ = self.context.engine.messages.deleteBotPreviewsLanguage(peerId: peerId, language: id, media: mappedMedia).startStandalone() + + if self.currentBotPreviewLanguages != botPreviewLanguages { + self.currentBotPreviewLanguages = botPreviewLanguages + if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { + self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate) + } + } + + self.setBotPreviewLanguage(id: nil, assumeEmpty: false) + } + + private func setBotPreviewLanguage(id: String?, assumeEmpty: Bool) { + guard case let .botPreview(peerId) = self.scope else { + return + } + if let listSource = self.listSource as? BotPreviewStoryListContext, listSource.language == id { + return + } + + if let id { + if let cachedListSource = self.cachedListSources[id] { + self.listSource = cachedListSource + } else { + let listSource = BotPreviewStoryListContext(account: self.context.account, engine: self.context.engine, peerId: peerId, language: id, assumeEmpty: assumeEmpty) + self.listSource = listSource + self.cachedListSources[id] = listSource + } + } else { + self.listSource = self.defaultListSource + } + + self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: true) } public func beginReordering() { @@ -4027,7 +4340,17 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if !isReordering, let reorderedIds = self.reorderedIds { self.reorderedIds = nil if case .botPreview = self.scope, let listSource = self.listSource as? BotPreviewStoryListContext { - listSource.reorderItems(ids: reorderedIds) + if let items = self.items { + var reorderedMedia: [Media] = [] + + for id in reorderedIds { + if let item = items.items.first(where: { ($0 as? VisualMediaItem)?.storyId == id }) as? VisualMediaItem { + reorderedMedia.append(item.story.media._asMedia()) + } + } + + listSource.reorderItems(media: reorderedMedia) + } } else if case let .peer(id, _, _) = self.scope, id == self.context.account.peerId, let items = self.items { var updatedPinnedIds: [Int32] = [] for id in reorderedIds { diff --git a/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/BUILD b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/BUILD new file mode 100644 index 0000000000..802cc7a542 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/BUILD @@ -0,0 +1,31 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "LanguageSelectionScreen", + module_name = "LanguageSelectionScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/AsyncDisplayKit", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/MergeLists", + "//submodules/ItemListUI", + "//submodules/PresentationDataUtils", + "//submodules/AccountContext", + "//submodules/SearchBarNode", + "//submodules/SearchUI", + "//submodules/TelegramUIPreferences", + "//submodules/TranslateUI", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreen.swift b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreen.swift new file mode 100644 index 0000000000..04229ce599 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreen.swift @@ -0,0 +1,177 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import AccountContext +import SearchUI + +public class LanguageSelectionScreen: ViewController { + private let context: AccountContext + private let selectLocalization: (LocalizationInfo) -> Void + + private var controllerNode: LanguageSelectionScreenNode { + return self.displayNode as! LanguageSelectionScreenNode + } + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private var searchContentNode: NavigationBarSearchContentNode? + + private var previousContentOffset: ListViewVisibleContentOffset? + + public init(context: AccountContext, selectLocalization: @escaping (LocalizationInfo) -> Void) { + self.context = context + self.selectLocalization = selectLocalization + + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + + super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData)) + + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style + self.navigationPresentation = .modal + + //TODO:localize + self.title = "Add a Translation" + + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + if let searchContentNode = strongSelf.searchContentNode { + searchContentNode.updateExpansionProgress(1.0, animated: true) + } + strongSelf.controllerNode.scrollToTop() + } + } + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings() + } + } + }) + + self.searchContentNode = NavigationBarSearchContentNode(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search, inline: true, activate: { [weak self] in + self?.activateSearch() + }) + self.navigationBar?.setContentNode(self.searchContentNode, animated: false) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + private func updateThemeAndStrings() { + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style + self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData)) + self.searchContentNode?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search) + self.title = self.presentationData.strings.Settings_AppLanguage + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + self.controllerNode.updatePresentationData(self.presentationData) + } + + override public func loadDisplayNode() { + self.displayNode = LanguageSelectionScreenNode(context: self.context, presentationData: self.presentationData, navigationBar: self.navigationBar!, requestActivateSearch: { [weak self] in + self?.activateSearch() + }, requestDeactivateSearch: { [weak self] in + self?.deactivateSearch() + }, present: { [weak self] c, a in + self?.present(c, in: .window(.root), with: a) + }, push: { [weak self] c in + self?.push(c) + }, selectLocalization: { [weak self] info in + guard let self else { + return + } + self.selectLocalization(info) + self.dismiss() + }) + + self.controllerNode.listNode.visibleContentOffsetChanged = { [weak self] offset in + if let strongSelf = self { + if let searchContentNode = strongSelf.searchContentNode { + searchContentNode.updateListVisibleContentOffset(offset) + } + + var previousContentOffsetValue: CGFloat? + if let previousContentOffset = strongSelf.previousContentOffset, case let .known(value) = previousContentOffset { + previousContentOffsetValue = value + } + switch offset { + case let .known(value): + let transition: ContainedViewLayoutTransition + if let previousContentOffsetValue = previousContentOffsetValue, value <= 0.0, previousContentOffsetValue > 30.0 { + transition = .animated(duration: 0.2, curve: .easeInOut) + } else { + transition = .immediate + } + strongSelf.navigationBar?.updateBackgroundAlpha(min(30.0, max(0.0, value - 54.0)) / 30.0, transition: transition) + case .unknown, .none: + strongSelf.navigationBar?.updateBackgroundAlpha(1.0, transition: .immediate) + } + + strongSelf.previousContentOffset = offset + } + } + + self.controllerNode.listNode.didEndScrolling = { [weak self] _ in + if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode { + let _ = fixNavigationSearchableListNodeScrolling(strongSelf.controllerNode.listNode, searchNode: searchContentNode) + } + } + + self._ready.set(self.controllerNode._ready.get()) + + self.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) + + self.displayNodeDidLoad() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, transition: transition) + } + + private func activateSearch() { + if self.displayNavigationBar { + if let scrollToTop = self.scrollToTop { + scrollToTop() + } + if let searchContentNode = self.searchContentNode { + self.controllerNode.activateSearch(placeholderNode: searchContentNode.placeholderNode) + } + self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring)) + } + } + + private func deactivateSearch() { + if !self.displayNavigationBar { + self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring)) + if let searchContentNode = self.searchContentNode { + self.controllerNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode) + } + } + } +} diff --git a/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift new file mode 100644 index 0000000000..ef9438ed6e --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift @@ -0,0 +1,568 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import MergeLists +import ItemListUI +import PresentationDataUtils +import AccountContext +import SearchBarNode +import SearchUI +import TelegramUIPreferences +import TranslateUI + +private enum LanguageListSection: ItemListSectionId { + case official + case unofficial +} + +private enum LanguageListEntryId: Hashable { + case localizationTitle + case localization(String) +} + +private enum LanguageListEntryType { + case official + case unofficial +} + +private enum LanguageListEntry: Comparable, Identifiable { + case localizationTitle(text: String, section: ItemListSectionId) + case localization(index: Int, info: LocalizationInfo?, type: LanguageListEntryType) + + var stableId: LanguageListEntryId { + switch self { + case .localizationTitle: + return .localizationTitle + case let .localization(index, info, _): + return .localization(info?.languageCode ?? "\(index)") + } + } + + private func index() -> Int { + switch self { + case .localizationTitle: + return 1000 + case let .localization(index, _, _): + return 1001 + index + } + } + + static func <(lhs: LanguageListEntry, rhs: LanguageListEntry) -> Bool { + return lhs.index() < rhs.index() + } + + func item(presentationData: PresentationData, searchMode: Bool, openSearch: @escaping () -> Void, selectLocalization: @escaping (LocalizationInfo) -> Void) -> ListViewItem { + switch self { + case let .localizationTitle(text, section): + return ItemListSectionHeaderItem(presentationData: ItemListPresentationData(presentationData), text: text, sectionId: section) + case let .localization(_, info, type): + return LocalizationListItem(presentationData: ItemListPresentationData(presentationData), id: info?.languageCode ?? "", title: info?.title ?? " ", subtitle: info?.localizedTitle ?? " ", checked: false, activity: false, loading: info == nil, editing: LocalizationListItemEditing(editable: false, editing: false, revealed: false, reorderable: false), sectionId: type == .official ? LanguageListSection.official.rawValue : LanguageListSection.unofficial.rawValue, alwaysPlain: searchMode, action: { + if let info { + selectLocalization(info) + } + }, setItemWithRevealedOptions: nil, removeItem: nil) + } + } +} + +private struct LocalizationListSearchContainerTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let isSearching: Bool +} + +private func preparedLanguageListSearchContainerTransition(presentationData: PresentationData, from fromEntries: [LanguageListEntry], to toEntries: [LanguageListEntry], selectLocalization: @escaping (LocalizationInfo) -> Void, isSearching: Bool, forceUpdate: Bool) -> LocalizationListSearchContainerTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries, allUpdated: forceUpdate) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: true, openSearch: {}, selectLocalization: selectLocalization), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: true, openSearch: {}, selectLocalization: selectLocalization), directionHint: nil) } + + return LocalizationListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching) +} + +private final class LocalizationListSearchContainerNode: SearchDisplayControllerContentNode { + private let dimNode: ASDisplayNode + private let listNode: ListView + + private var enqueuedTransitions: [LocalizationListSearchContainerTransition] = [] + private var hasValidLayout = false + + private let searchQuery = Promise() + private let searchDisposable = MetaDisposable() + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private let presentationDataPromise: Promise + + public override var hasDim: Bool { + return true + } + + init(context: AccountContext, listState: LocalizationListState, selectLocalization: @escaping (LocalizationInfo) -> Void) { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.presentationData = presentationData + + self.presentationDataPromise = Promise(self.presentationData) + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5) + + self.listNode = ListView() + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + super.init() + + self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor + self.listNode.isHidden = true + + self.addSubnode(self.dimNode) + self.addSubnode(self.listNode) + + let foundItems = self.searchQuery.get() + |> mapToSignal { query -> Signal<[LocalizationInfo]?, NoError> in + if let query = query, !query.isEmpty { + let normalizedQuery = query.lowercased() + var result: [LocalizationInfo] = [] + var uniqueIds = Set() + for info in listState.availableSavedLocalizations + listState.availableOfficialLocalizations { + if info.title.lowercased().hasPrefix(normalizedQuery) || info.localizedTitle.lowercased().hasPrefix(normalizedQuery) { + if uniqueIds.contains(info.languageCode) { + continue + } + uniqueIds.insert(info.languageCode) + result.append(info) + } + } + return .single(result) + } else { + return .single(nil) + } + } + + let previousEntriesHolder = Atomic<([LanguageListEntry], PresentationTheme, PresentationStrings)?>(value: nil) + self.searchDisposable.set(combineLatest(queue: .mainQueue(), foundItems, self.presentationDataPromise.get()).start(next: { [weak self] items, presentationData in + guard let strongSelf = self else { + return + } + var entries: [LanguageListEntry] = [] + if let items = items { + for item in items { + entries.append(.localization(index: entries.count, info: item, type: .official)) + } + } + let previousEntriesAndPresentationData = previousEntriesHolder.swap((entries, presentationData.theme, presentationData.strings)) + let transition = preparedLanguageListSearchContainerTransition(presentationData: presentationData, from: previousEntriesAndPresentationData?.0 ?? [], to: entries, selectLocalization: selectLocalization, isSearching: items != nil, forceUpdate: previousEntriesAndPresentationData?.1 !== presentationData.theme || previousEntriesAndPresentationData?.2 !== presentationData.strings) + strongSelf.enqueueTransition(transition) + })) + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings(theme: presentationData.theme, strings: presentationData.strings) + strongSelf.presentationDataPromise.set(.single(presentationData)) + } + } + }) + + self.listNode.beganInteractiveDragging = { [weak self] _ in + self?.dismissInput?() + } + } + + deinit { + self.searchDisposable.dispose() + self.presentationDataDisposable?.dispose() + } + + override func didLoad() { + super.didLoad() + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.listNode.backgroundColor = theme.chatList.backgroundColor + } + + override func searchTextUpdated(text: String) { + if text.isEmpty { + self.searchQuery.set(.single(nil)) + } else { + self.searchQuery.set(.single(text)) + } + } + + private func enqueueTransition(_ transition: LocalizationListSearchContainerTransition) { + self.enqueuedTransitions.append(transition) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransitions() + } + } + } + + private func dequeueTransitions() { + if let transition = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + options.insert(.PreferSynchronousDrawing) + + let isSearching = transition.isSearching + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + self?.listNode.isHidden = !isSearching + self?.dimNode.isHidden = isSearching + }) + } + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + + let topInset = navigationBarHeight + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset))) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + + self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: navigationBarHeight, left: layout.safeInsets.left, bottom: layout.insets(options: [.input]).bottom, right: layout.safeInsets.right), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !self.hasValidLayout { + self.hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransitions() + } + } + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancel?() + } + } +} + +private struct LanguageListNodeTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let firstTime: Bool + let isLoading: Bool + let animated: Bool + let crossfade: Bool +} + +private func preparedLanguageListNodeTransition( + presentationData: PresentationData, + from fromEntries: [LanguageListEntry], + to toEntries: [LanguageListEntry], + openSearch: @escaping () -> Void, + selectLocalization: @escaping (LocalizationInfo) -> Void, + firstTime: Bool, + isLoading: Bool, + forceUpdate: Bool, + animated: Bool, + crossfade: Bool +) -> LanguageListNodeTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries, allUpdated: forceUpdate) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: false, openSearch: openSearch, selectLocalization: selectLocalization), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: false, openSearch: openSearch, selectLocalization: selectLocalization), directionHint: nil) } + + return LanguageListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, firstTime: firstTime, isLoading: isLoading, animated: animated, crossfade: crossfade) +} + +final class LanguageSelectionScreenNode: ViewControllerTracingNode { + private let context: AccountContext + private var presentationData: PresentationData + private weak var navigationBar: NavigationBar? + private let requestActivateSearch: () -> Void + private let requestDeactivateSearch: () -> Void + private let present: (ViewController, Any?) -> Void + private let push: (ViewController) -> Void + private let selectLocalization: (LocalizationInfo) -> Void + + private var didSetReady = false + let _ready = ValuePromise() + + private var containerLayout: (ContainerViewLayout, CGFloat)? + let listNode: ListView + private let leftOverlayNode: ASDisplayNode + private let rightOverlayNode: ASDisplayNode + private var queuedTransitions: [LanguageListNodeTransition] = [] + private var searchDisplayController: SearchDisplayController? + + private let presentationDataValue = Promise() + private var updatedDisposable: Disposable? + private var listDisposable: Disposable? + + private var currentListState: LocalizationListState? + + init(context: AccountContext, presentationData: PresentationData, navigationBar: NavigationBar, requestActivateSearch: @escaping () -> Void, requestDeactivateSearch: @escaping () -> Void, present: @escaping (ViewController, Any?) -> Void, push: @escaping (ViewController) -> Void, selectLocalization: @escaping (LocalizationInfo) -> Void) { + self.context = context + self.presentationData = presentationData + self.presentationDataValue.set(.single(presentationData)) + self.navigationBar = navigationBar + self.requestActivateSearch = requestActivateSearch + self.requestDeactivateSearch = requestDeactivateSearch + self.present = present + self.push = push + self.selectLocalization = selectLocalization + + self.listNode = ListView() + self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + self.leftOverlayNode = ASDisplayNode() + self.leftOverlayNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor + self.rightOverlayNode = ASDisplayNode() + self.rightOverlayNode.backgroundColor = self.presentationData.theme.list.blocksBackgroundColor + + super.init() + + self.backgroundColor = presentationData.theme.list.blocksBackgroundColor + self.addSubnode(self.listNode) + + let openSearch: () -> Void = { + requestActivateSearch() + } + + let previousState = Atomic(value: nil) + let previousEntriesHolder = Atomic<([LanguageListEntry], PresentationTheme, PresentationStrings)?>(value: nil) + self.listDisposable = combineLatest( + queue: .mainQueue(), + context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.LocalizationList()), + context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)), + context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.localizationSettings, ApplicationSpecificSharedDataKeys.translationSettings]), + self.presentationDataValue.get() + ).start(next: { [weak self] localizationListState, peer, sharedData, presentationData in + guard let strongSelf = self else { + return + } + + var entries: [LanguageListEntry] = [] + var existingIds = Set() + + if !localizationListState.availableOfficialLocalizations.isEmpty { + strongSelf.currentListState = localizationListState + + let availableSavedLocalizations = localizationListState.availableSavedLocalizations.filter({ info in !localizationListState.availableOfficialLocalizations.contains(where: { $0.languageCode == info.languageCode }) }) + if !availableSavedLocalizations.isEmpty { + //entries.append(.localizationTitle(text: presentationData.strings.Localization_InterfaceLanguage.uppercased(), section: LanguageListSection.unofficial.rawValue)) + for info in availableSavedLocalizations { + if existingIds.contains(info.languageCode) { + continue + } + existingIds.insert(info.languageCode) + entries.append(.localization(index: entries.count, info: info, type: .unofficial)) + } + } else { + //entries.append(.localizationTitle(text: presentationData.strings.Localization_InterfaceLanguage.uppercased(), section: LanguageListSection.official.rawValue)) + } + for info in localizationListState.availableOfficialLocalizations { + if existingIds.contains(info.languageCode) { + continue + } + existingIds.insert(info.languageCode) + entries.append(.localization(index: entries.count, info: info, type: .official)) + } + } else { + for _ in 0 ..< 15 { + entries.append(.localization(index: entries.count, info: nil, type: .official)) + } + } + + let previousState = previousState.swap(localizationListState) + + let previousEntriesAndPresentationData = previousEntriesHolder.swap((entries, presentationData.theme, presentationData.strings)) + let transition = preparedLanguageListNodeTransition( + presentationData: presentationData, + from: previousEntriesAndPresentationData?.0 ?? [], + to: entries, + openSearch: openSearch, + selectLocalization: { [weak self] info in + self?.selectLocalization(info) + }, + firstTime: previousEntriesAndPresentationData == nil, + isLoading: entries.isEmpty, + forceUpdate: previousEntriesAndPresentationData?.1 !== presentationData.theme || previousEntriesAndPresentationData?.2 !== presentationData.strings, + animated: (previousEntriesAndPresentationData?.0.count ?? 0) != entries.count, + crossfade: (previousState == nil || previousState!.availableOfficialLocalizations.isEmpty) != localizationListState.availableOfficialLocalizations.isEmpty + ) + strongSelf.enqueueTransition(transition) + }) + self.updatedDisposable = context.engine.localization.synchronizedLocalizationListState().start() + + self.listNode.itemNodeHitTest = { [weak self] point in + if let strongSelf = self { + return point.x > strongSelf.leftOverlayNode.frame.maxX && point.x < strongSelf.rightOverlayNode.frame.minX + } else { + return true + } + } + } + + deinit { + self.listDisposable?.dispose() + self.updatedDisposable?.dispose() + } + + func updatePresentationData(_ presentationData: PresentationData) { + let stringsUpdated = self.presentationData.strings !== presentationData.strings + self.presentationData = presentationData + + if stringsUpdated { + if let snapshotView = self.view.snapshotView(afterScreenUpdates: false) { + self.view.addSubview(snapshotView) + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + } + + self.presentationDataValue.set(.single(presentationData)) + self.backgroundColor = presentationData.theme.list.blocksBackgroundColor + self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) + self.searchDisplayController?.updatePresentationData(presentationData) + self.leftOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor + self.rightOverlayNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + let hadValidLayout = self.containerLayout != nil + self.containerLayout = (layout, navigationBarHeight) + + var listInsets = layout.insets(options: [.input]) + listInsets.top += navigationBarHeight + if layout.size.width >= 375.0 { + let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0)) + listInsets.left += inset + listInsets.right += inset + } else { + listInsets.left += layout.safeInsets.left + listInsets.right += layout.safeInsets.right + } + + self.leftOverlayNode.frame = CGRect(x: 0.0, y: 0.0, width: listInsets.left, height: layout.size.height) + self.rightOverlayNode.frame = CGRect(x: layout.size.width - listInsets.right, y: 0.0, width: listInsets.right, height: layout.size.height) + + if self.leftOverlayNode.supernode == nil { + self.insertSubnode(self.leftOverlayNode, aboveSubnode: self.listNode) + } + if self.rightOverlayNode.supernode == nil { + self.insertSubnode(self.rightOverlayNode, aboveSubnode: self.listNode) + } + + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + + self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: listInsets, duration: duration, curve: curve) + + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hadValidLayout { + self.dequeueTransitions() + } + } + + private func enqueueTransition(_ transition: LanguageListNodeTransition) { + self.queuedTransitions.append(transition) + + if self.containerLayout != nil { + self.dequeueTransitions() + } + } + + private func dequeueTransitions() { + guard let _ = self.containerLayout else { + return + } + while !self.queuedTransitions.isEmpty { + let transition = self.queuedTransitions.removeFirst() + + var options = ListViewDeleteAndInsertOptions() + if transition.firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if transition.crossfade { + options.insert(.AnimateCrossfade) + } else if transition.animated { + options.insert(.AnimateInsertion) + } + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(true) + } + } + }) + } + } + + func activateSearch(placeholderNode: SearchBarPlaceholderNode) { + guard let (containerLayout, navigationBarHeight) = self.containerLayout, self.searchDisplayController == nil else { + return + } + + self.searchDisplayController = SearchDisplayController( + presentationData: self.presentationData, + contentNode: LocalizationListSearchContainerNode( + context: self.context, + listState: self.currentListState ?? LocalizationListState.defaultSettings, + selectLocalization: { [weak self] info in + self?.selectLocalization(info) + }), + inline: true, + cancel: { [weak self] in + self?.requestDeactivateSearch() + } + ) + + self.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, transition: .immediate) + self.searchDisplayController?.activate(insertSubnode: { [weak self, weak placeholderNode] subnode, isSearchBar in + if let strongSelf = self, let strongPlaceholderNode = placeholderNode { + if isSearchBar { + strongPlaceholderNode.supernode?.insertSubnode(subnode, aboveSubnode: strongPlaceholderNode) + } else if let navigationBar = strongSelf.navigationBar { + strongSelf.insertSubnode(subnode, belowSubnode: navigationBar) + } + } + }, placeholder: placeholderNode) + } + + func deactivateSearch(placeholderNode: SearchBarPlaceholderNode) { + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.deactivate(placeholder: placeholderNode) + self.searchDisplayController = nil + } + } + + func scrollToTop() { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } +} diff --git a/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift b/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift index 6ba47f35df..0ce8b9d798 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift @@ -1093,7 +1093,7 @@ final class ThemeAccentColorControllerNode: ASDisplayNode, ASScrollViewDelegate state.displayPatternPanel = false return state }, animated: true) - }, clickThroughMessage: { + }, clickThroughMessage: { _, _ in }, backgroundNode: self.backgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false) return item } diff --git a/submodules/TelegramUI/Components/SpaceWarpView/BUILD b/submodules/TelegramUI/Components/SpaceWarpView/BUILD index 44c7e34a6d..a3c6d68639 100644 --- a/submodules/TelegramUI/Components/SpaceWarpView/BUILD +++ b/submodules/TelegramUI/Components/SpaceWarpView/BUILD @@ -12,6 +12,7 @@ swift_library( deps = [ "//submodules/Display", "//submodules/AsyncDisplayKit", + "//submodules/ComponentFlow", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift b/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift index 7e4ec16830..fa27dca70d 100644 --- a/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift +++ b/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift @@ -2,7 +2,429 @@ import Foundation import UIKit import Display import AsyncDisplayKit +import ComponentFlow + +/*open class SpaceWarpView: UIView { + private final class WarpPartView: UIView { + let cloneView: PortalView + + init?(contentView: PortalSourceView) { + guard let cloneView = PortalView(matchPosition: false) else { + return nil + } + self.cloneView = cloneView + + super.init(frame: CGRect()) + + self.layer.anchorPoint = CGPoint(x: 0.5, y: 0.0) + + self.clipsToBounds = true + self.addSubview(cloneView.view) + contentView.addPortal(view: cloneView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(containerSize: CGSize, rect: CGRect, transition: ComponentTransition) { + transition.setFrame(view: self.cloneView.view, frame: CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: CGSize(width: containerSize.width, height: containerSize.height))) + } + } + + public var contentView: UIView { + return self.contentViewImpl + } + + let contentViewImpl: PortalSourceView + + private var warpViews: [WarpPartView] = [] + + override public init(frame: CGRect) { + self.contentViewImpl = PortalSourceView() + + super.init(frame: frame) + + self.addSubview(self.contentView) + self.contentView.alpha = 0.1 + + for _ in 0 ..< 8 { + if let warpView = WarpPartView(contentView: self.contentViewImpl) { + self.warpViews.append(warpView) + self.addSubview(warpView) + } + } + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func update(size: CGSize, warpHeight: CGFloat, transition: ComponentTransition) { + transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: size)) + + let allItemsHeight = warpHeight * 0.5 + for i in 0 ..< self.warpViews.count { + let itemHeight = warpHeight / CGFloat(self.warpViews.count) + let itemFraction = CGFloat(i + 1) / CGFloat(self.warpViews.count) + let _ = itemHeight + + let da = CGFloat.pi * 0.5 / CGFloat(self.warpViews.count) + let alpha = CGFloat.pi * 0.5 - itemFraction * CGFloat.pi * 0.5 + let endPoint = CGPoint(x: cos(alpha), y: sin(alpha)) + let prevAngle = alpha + da + let prevPt = CGPoint(x: cos(prevAngle), y: sin(prevAngle)) + var angle: CGFloat + angle = -atan2(endPoint.y - prevPt.y, endPoint.x - prevPt.x) + + let itemLengthVector = CGPoint(x: endPoint.x - prevPt.x, y: endPoint.y - prevPt.y) + let itemLength = sqrt(itemLengthVector.x * itemLengthVector.x + itemLengthVector.y * itemLengthVector.y) * warpHeight * 0.5 + let _ = itemLength + + var transform: CATransform3D + transform = CATransform3DIdentity + transform.m34 = 1.0 / 240.0 + + transform = CATransform3DTranslate(transform, 0.0, prevPt.x * allItemsHeight, (1.0 - prevPt.y) * allItemsHeight) + transform = CATransform3DRotate(transform, angle, 1.0, 0.0, 0.0) + + let positionY = size.height - allItemsHeight + 4.0 + CGFloat(i) * itemLength + let rect = CGRect(origin: CGPoint(x: 0.0, y: positionY), size: CGSize(width: size.width, height: itemLength)) + transition.setPosition(view: self.warpViews[i], position: CGPoint(x: rect.midX, y: 4.0)) + transition.setBounds(view: self.warpViews[i], bounds: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: itemLength))) + transition.setTransform(view: self.warpViews[i], transform: transform) + self.warpViews[i].update(containerSize: size, rect: rect, transition: transition) + } + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return self.contentView.hitTest(point, with: event) + } +}*/ + +private extension CGPoint { + static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint { + return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) + } + + static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint { + return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) + } + + static func *(lhs: CGPoint, rhs: CGFloat) -> CGPoint { + return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs) + } +} + +private func length(_ v: CGPoint) -> CGFloat { + return sqrt(v.x * v.x + v.y * v.y) +} + +private func normalize(_ v: CGPoint) -> CGPoint { + let len = length(v) + return CGPoint(x: v.x / len, y: v.y / len) +} + +private struct RippleParams { + var amplitude: CGFloat + var frequency: CGFloat + var decay: CGFloat + var speed: CGFloat + + init(amplitude: CGFloat, frequency: CGFloat, decay: CGFloat, speed: CGFloat) { + self.amplitude = amplitude + self.frequency = frequency + self.decay = decay + self.speed = speed + } +} + +private func transformCoordinate( + position: CGPoint, + origin: CGPoint, + time: CGFloat, + params: RippleParams +) -> CGPoint { + // The distance of the current pixel position from `origin`. + let distance = length(position - origin) + + if distance < 10.0 { + return position + } + + // The amount of time it takes for the ripple to arrive at the current pixel position. + let delay = distance / params.speed + + // Adjust for delay, clamp to 0. + var time = time + time -= delay + time = max(0.0, time) + + // The ripple is a sine wave that Metal scales by an exponential decay + // function. + let rippleAmount = params.amplitude * sin(params.frequency * time) * exp(-params.decay * time) + + // A vector of length `amplitude` that points away from position. + let n = normalize(position - origin) + + // Scale `n` by the ripple amount at the current pixel position and add it + // to the current pixel position. + // + // This new position moves toward or away from `origin` based on the + // sign and magnitude of `rippleAmount`. + let newPosition = position + n * rippleAmount + return newPosition +} + +private func rectToQuad( + rect: CGRect, + quadTL: CGPoint, + quadTR: CGPoint, + quadBL: CGPoint, + quadBR: CGPoint +) -> CATransform3D { + let x1a = quadTL.x + let y1a = quadTL.y + let x2a = quadTR.x + let y2a = quadTR.y + let x3a = quadBL.x + let y3a = quadBL.y + let x4a = quadBR.x + let y4a = quadBR.y + + let X = rect.origin.x + let Y = rect.origin.y + let W = rect.size.width + let H = rect.size.height + + let y21 = y2a - y1a + let y32 = y3a - y2a + let y43 = y4a - y3a + let y14 = y1a - y4a + let y31 = y3a - y1a + let y42 = y4a - y2a + + let a = -H*(x2a*x3a*y14 + x2a*x4a*y31 - x1a*x4a*y32 + x1a*x3a*y42) + let b = W*(x2a*x3a*y14 + x3a*x4a*y21 + x1a*x4a*y32 + x1a*x2a*y43) + let c = H*X*(x2a*x3a*y14 + x2a*x4a*y31 - x1a*x4a*y32 + x1a*x3a*y42) - H*W*x1a*(x4a*y32 - x3a*y42 + x2a*y43) - W*Y*(x2a*x3a*y14 + x3a*x4a*y21 + x1a*x4a*y32 + x1a*x2a*y43) + + let d = H*(-x4a*y21*y3a + x2a*y1a*y43 - x1a*y2a*y43 - x3a*y1a*y4a + x3a*y2a*y4a) + let e = W*(x4a*y2a*y31 - x3a*y1a*y42 - x2a*y31*y4a + x1a*y3a*y42) + let f = -(W*(x4a*(Y*y2a*y31 + H*y1a*y32) - x3a*(H + Y)*y1a*y42 + H*x2a*y1a*y43 + x2a*Y*(y1a - y3a)*y4a + x1a*Y*y3a*(-y2a + y4a)) - H*X*(x4a*y21*y3a - x2a*y1a*y43 + x3a*(y1a - y2a)*y4a + x1a*y2a*(-y3a + y4a))) + + let g = H*(x3a*y21 - x4a*y21 + (-x1a + x2a)*y43) + let h = W*(-x2a*y31 + x4a*y31 + (x1a - x3a)*y42) + var i = W*Y*(x2a*y31 - x4a*y31 - x1a*y42 + x3a*y42) + H*(X*(-(x3a*y21) + x4a*y21 + x1a*y43 - x2a*y43) + W*(-(x3a*y2a) + x4a*y2a + x2a*y3a - x4a*y3a - x2a*y4a + x3a*y4a)) + + let kEpsilon = 0.0001 + + if fabs(i) < kEpsilon { + i = kEpsilon * (i > 0 ? 1.0 : -1.0) + } + + //CATransform3D transform = {a/i, d/i, 0, g/i, b/i, e/i, 0, h/i, 0, 0, 1, 0, c/i, f/i, 0, 1.0} + let transform = CATransform3D(m11: a/i, m12: d/i, m13: 0, m14: g/i, m21: b/i, m22: e/i, m23: 0, m24: h/i, m31: 0, m32: 0, m33: 1, m34: 0, m41: c/i, m42: f/i, m43: 0, m44: 1.0) + return transform +} open class SpaceWarpView: UIView { + private final class GridView: UIView { + let cloneView: PortalView + let gridPosition: CGPoint + + init?(contentView: PortalSourceView, gridPosition: CGPoint) { + self.gridPosition = gridPosition + + guard let cloneView = PortalView(matchPosition: false) else { + return nil + } + self.cloneView = cloneView + + super.init(frame: CGRect()) + + self.layer.anchorPoint = CGPoint(x: 0.0, y: 0.0) + + self.clipsToBounds = true + self.isUserInteractionEnabled = false + self.addSubview(cloneView.view) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateIsActive(contentView: PortalSourceView, isActive: Bool) { + if isActive { + contentView.addPortal(view: self.cloneView) + } else { + contentView.removePortal(view: self.cloneView) + } + } + + func update(containerSize: CGSize, rect: CGRect, transition: ComponentTransition) { + transition.setFrame(view: self.cloneView.view, frame: CGRect(origin: CGPoint(x: -rect.minX - containerSize.width * 0.5, y: -rect.minY - containerSize.height * 0.5), size: CGSize(width: containerSize.width, height: containerSize.height))) + } + } + private var gridViews: [GridView] = [] + + public var contentView: UIView { + return self.contentViewImpl + } + + let contentViewImpl: PortalSourceView + + private var link: SharedDisplayLinkDriver.Link? + private var startPoint: CGPoint? + + private var timeValue: CGFloat = 0.0 + private var currentActiveViews: Int = 0 + + private var resolution: (x: Int, y: Int)? + private var size: CGSize? + + override public init(frame: CGRect) { + self.contentViewImpl = PortalSourceView() + + super.init(frame: frame) + + self.addSubview(self.contentView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func trigger(at point: CGPoint) { + self.startPoint = point + self.timeValue = 0.0 + + if self.link == nil { + self.link = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] deltaTime in + guard let self else { + return + } + self.timeValue += deltaTime * (1.0 / CGFloat(UIView.animationDurationFactor())) + + if let size = self.size { + self.update(size: size, transition: .immediate) + } + }) + } + } + + private func updateGrid(resolutionX: Int, resolutionY: Int) { + if let resolution = self.resolution, resolution.x == resolutionX, resolution.y == resolutionY { + return + } + self.resolution = (resolutionX, resolutionY) + + for gridView in self.gridViews { + gridView.removeFromSuperview() + } + + var gridViews: [GridView] = [] + for y in 0 ..< resolutionY { + for x in 0 ..< resolutionX { + if let gridView = GridView(contentView: self.contentViewImpl, gridPosition: CGPoint(x: CGFloat(x) / CGFloat(resolutionX), y: CGFloat(y) / CGFloat(resolutionY))) { + gridView.isUserInteractionEnabled = false + gridViews.append(gridView) + self.addSubview(gridView) + } + } + } + self.gridViews = gridViews + } + + public func update(size: CGSize, transition: ComponentTransition) { + self.size = size + if size.width <= 0.0 || size.height <= 0.0 { + return + } + + self.updateGrid(resolutionX: max(2, Int(size.width / 100.0)), resolutionY: max(2, Int(size.height / 100.0))) + guard let resolution = self.resolution else { + return + } + + //let pixelStep = CGPoint(x: CGFloat(resolution.x) * 0.33, y: CGFloat(resolution.y) * 0.33) + let pixelStep = CGPoint() + let itemSize = CGSize(width: size.width / CGFloat(resolution.x), height: size.height / CGFloat(resolution.y)) + + let params = RippleParams(amplitude: 22.0, frequency: 15.0, decay: 8.0, speed: 1400.0) + + var activeViews = 0 + for gridView in self.gridViews { + let sourceRect = CGRect(origin: CGPoint(x: gridView.gridPosition.x * (size.width + pixelStep.x), y: gridView.gridPosition.y * (size.height + pixelStep.y)), size: itemSize) + + gridView.bounds = CGRect(origin: CGPoint(), size: sourceRect.size) + gridView.update(containerSize: size, rect: sourceRect, transition: transition) + + let initialTopLeft = CGPoint(x: sourceRect.minX, y: sourceRect.minY) + let initialTopRight = CGPoint(x: sourceRect.maxX, y: sourceRect.minY) + let initialBottomLeft = CGPoint(x: sourceRect.minX, y: sourceRect.maxY) + let initialBottomRight = CGPoint(x: sourceRect.maxX, y: sourceRect.maxY) + + var topLeft = initialTopLeft + var topRight = initialTopRight + var bottomLeft = initialBottomLeft + var bottomRight = initialBottomRight + + if let startPoint = self.startPoint { + topLeft = transformCoordinate(position: topLeft, origin: startPoint, time: self.timeValue, params: params) + topRight = transformCoordinate(position: topRight, origin: startPoint, time: self.timeValue, params: params) + bottomLeft = transformCoordinate(position: bottomLeft, origin: startPoint, time: self.timeValue, params: params) + bottomRight = transformCoordinate(position: bottomRight, origin: startPoint, time: self.timeValue, params: params) + } + + let distanceTopLeft = length(topLeft - initialTopLeft) + let distanceTopRight = length(topRight - initialTopRight) + let distanceBottomLeft = length(bottomLeft - initialBottomLeft) + let distanceBottomRight = length(bottomRight - initialBottomRight) + var maxDistance = max(distanceTopLeft, distanceTopRight) + maxDistance = max(maxDistance, distanceBottomLeft) + maxDistance = max(maxDistance, distanceBottomRight) + + let isActive: Bool + if maxDistance <= 0.5 { + gridView.layer.transform = CATransform3DIdentity + isActive = false + } else { + let transform = rectToQuad(rect: CGRect(origin: CGPoint(), size: itemSize), quadTL: topLeft, quadTR: topRight, quadBL: bottomLeft, quadBR: bottomRight) + gridView.layer.transform = transform + isActive = true + activeViews += 1 + } + if gridView.isHidden != !isActive { + gridView.isHidden = !isActive + gridView.updateIsActive(contentView: self.contentViewImpl, isActive: isActive) + } + } + + if self.currentActiveViews != activeViews { + self.currentActiveViews = activeViews + #if DEBUG + print("SpaceWarpView: activeViews = \(activeViews)") + #endif + } + } + + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.alpha.isZero || self.isHidden || !self.isUserInteractionEnabled { + return nil + } + for view in self.contentView.subviews.reversed() { + if let result = view.hitTest(self.convert(point, to: view), with: event), result.isUserInteractionEnabled { + return result + } + } + + let result = super.hitTest(point, with: event) + if result != self { + return result + } else { + return nil + } + } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 11b7d6932a..6e44780115 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -1655,9 +1655,8 @@ private final class StoryContainerScreenComponent: Component { } if case let .user(user) = slice.peer, user.botInfo != nil { - if let id = slice.item.storyItem.media.id { - let _ = component.context.engine.messages.deleteBotPreviews(peerId: slice.peer.id, ids: [id]).startStandalone() - } + //TODO:localize + let _ = component.context.engine.messages.deleteBotPreviews(peerId: slice.peer.id, language: nil, media: [slice.item.storyItem.media._asMedia()]).startStandalone() } else { let _ = component.context.engine.messages.deleteStories(peerId: slice.peer.id, ids: [slice.item.storyItem.id]).startStandalone() } diff --git a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift index 2a2536c59c..4f47e53ca3 100644 --- a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift +++ b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift @@ -22,11 +22,13 @@ public final class TabSelectorComponent: Component { public var font: UIFont public var spacing: CGFloat public var lineSelection: Bool + public var verticalInset: CGFloat - public init(font: UIFont, spacing: CGFloat, lineSelection: Bool = false) { + public init(font: UIFont, spacing: CGFloat, lineSelection: Bool = false, verticalInset: CGFloat = 0.0) { self.font = font self.spacing = spacing self.lineSelection = lineSelection + self.verticalInset = verticalInset } } @@ -92,7 +94,7 @@ public final class TabSelectorComponent: Component { } } - public final class View: UIView { + public final class View: UIScrollView { private var component: TabSelectorComponent? private weak var state: EmptyComponentState? @@ -104,6 +106,14 @@ public final class TabSelectorComponent: Component { super.init(frame: frame) + self.showsVerticalScrollIndicator = false + self.showsHorizontalScrollIndicator = false + self.scrollsToTop = false + self.delaysContentTouches = false + self.canCancelContentTouches = true + self.contentInsetAdjustmentBehavior = .never + self.alwaysBounceVertical = false + self.addSubview(self.selectionView) } @@ -114,6 +124,10 @@ public final class TabSelectorComponent: Component { deinit { } + override public func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let selectionColorUpdated = component.colors.selection != self.component?.colors.selection @@ -121,6 +135,12 @@ public final class TabSelectorComponent: Component { self.state = state let baseHeight: CGFloat = 28.0 + + var verticalInset: CGFloat = 0.0 + if let customLayout = component.customLayout { + verticalInset = customLayout.verticalInset * 2.0 + } + let innerInset: CGFloat = 12.0 let spacing: CGFloat = component.customLayout?.spacing ?? 2.0 @@ -148,7 +168,7 @@ public final class TabSelectorComponent: Component { } } - var contentWidth: CGFloat = 0.0 + var contentWidth: CGFloat = spacing var previousBackgroundRect: CGRect? var selectedBackgroundRect: CGRect? var nextBackgroundRect: CGRect? @@ -213,8 +233,8 @@ public final class TabSelectorComponent: Component { if !contentWidth.isZero { contentWidth += spacing } - let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) - let itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight)) + let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) + let itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight)) contentWidth = itemBackgroundRect.maxX if item.id == component.selectedId { @@ -237,6 +257,7 @@ public final class TabSelectorComponent: Component { } index += 1 } + contentWidth += spacing var removeIds: [AnyHashable] = [] for (id, itemView) in self.visibleItems { @@ -277,7 +298,14 @@ public final class TabSelectorComponent: Component { self.selectionView.alpha = 0.0 } - return CGSize(width: contentWidth, height: baseHeight) + self.contentSize = CGSize(width: contentWidth, height: baseHeight + verticalInset * 2.0) + self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width + + if let selectedBackgroundRect { + self.scrollRectToVisible(selectedBackgroundRect.insetBy(dx: -spacing, dy: 0.0), animated: false) + } + + return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight + verticalInset * 2.0) } } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 857a6a1e6f..60350b684e 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1816,8 +1816,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let context = self?.context, let navigationController = self?.effectiveNavigationController { let _ = context.sharedContext.navigateToForumThread(context: context, peerId: peerId, threadId: threadId, messageId: messageId, navigationController: navigationController, activateInput: nil, scrollToEndIfExists: false, keepStack: .always).startStandalone() } - }, tapMessage: nil, clickThroughMessage: { [weak self] in - self?.chatDisplayNode.dismissInput() + }, tapMessage: nil, clickThroughMessage: { [weak self] view, location in + self?.chatDisplayNode.dismissInput(view: view, location: location) }, toggleMessagesSelection: { [weak self] ids, value in guard let strongSelf = self, strongSelf.isNodeLoaded else { return diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 46432255a9..3c84f133a8 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -43,6 +43,7 @@ import ChatInlineSearchResultsListComponent import ComponentDisplayAdapters import ComponentFlow import ChatEmptyNode +import SpaceWarpView final class VideoNavigationControllerDropContentItem: NavigationControllerDropContentItem { let itemNode: OverlayMediaItemNode @@ -86,6 +87,41 @@ private struct ChatControllerNodeDerivedLayoutState { var upperInputPositionBound: CGFloat? } +class ChatNodeContainer: ASDisplayNode { + private let contentNodeImpl: ASDisplayNode + + var contentNode: ASDisplayNode { + if self.view is SpaceWarpView { + return self.contentNodeImpl + } else { + return self + } + } + + override init() { + self.contentNodeImpl = ASDisplayNode() + + super.init() + + #if DEBUG && false + self.setViewBlock({ + return SpaceWarpView(frame: CGRect()) + }) + #endif + + (self.view as? SpaceWarpView)?.contentView.addSubnode(self.contentNodeImpl) + } + + func triggerRipple(at point: CGPoint) { + (self.view as? SpaceWarpView)?.trigger(at: point) + } + + func update(size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.contentNodeImpl, frame: CGRect(origin: CGPoint(), size: size)) + (self.view as? SpaceWarpView)?.update(size: size, transition: ComponentTransition(transition)) + } +} + class HistoryNodeContainer: ASDisplayNode { var isSecret: Bool { didSet { @@ -95,7 +131,19 @@ class HistoryNodeContainer: ASDisplayNode { } } + private let contentNodeImpl: ASDisplayNode + + var contentNode: ASDisplayNode { + if self.view is SpaceWarpView { + return self.contentNodeImpl + } else { + return self + } + } + init(isSecret: Bool) { + self.contentNodeImpl = ASDisplayNode() + self.isSecret = isSecret super.init() @@ -103,6 +151,23 @@ class HistoryNodeContainer: ASDisplayNode { if self.isSecret { setLayerDisableScreenshots(self.layer, self.isSecret) } + + #if DEBUG && false + self.setViewBlock({ + return SpaceWarpView(frame: CGRect()) + }) + #endif + + (self.view as? SpaceWarpView)?.contentView.addSubnode(self.contentNodeImpl) + } + + func triggerRipple(at point: CGPoint) { + (self.view as? SpaceWarpView)?.trigger(at: point) + } + + func update(size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.contentNodeImpl, frame: CGRect(origin: CGPoint(), size: size)) + (self.view as? SpaceWarpView)?.update(size: size, transition: ComponentTransition(transition)) } } @@ -127,12 +192,12 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } - let contentContainerNode: ASDisplayNode + let contentContainerNode: ChatNodeContainer let contentDimNode: ASDisplayNode let backgroundNode: WallpaperBackgroundNode let historyNode: ChatHistoryListNodeImpl var blurredHistoryNode: ASImageNode? - let historyNodeContainer: ASDisplayNode + let historyNodeContainer: HistoryNodeContainer let loadingNode: ChatLoadingNode private(set) var loadingPlaceholderNode: ChatLoadingPlaceholderNode? @@ -379,7 +444,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.backgroundNode = backgroundNode - self.contentContainerNode = ASDisplayNode() + self.contentContainerNode = ChatNodeContainer() self.contentDimNode = ASDisplayNode() self.contentDimNode.isUserInteractionEnabled = false self.contentDimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.2) @@ -633,7 +698,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.historyNodeContainer = HistoryNodeContainer(isSecret: chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat) - self.historyNodeContainer.addSubnode(self.historyNode) + self.historyNodeContainer.contentNode.addSubnode(self.historyNode) var getContentAreaInScreenSpaceImpl: (() -> CGRect)? var onTransitionEventImpl: ((ContainedViewLayoutTransition) -> Void)? @@ -787,11 +852,11 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.historyNode.enableExtractedBackgrounds = true self.addSubnode(self.contentContainerNode) - self.contentContainerNode.addSubnode(self.backgroundNode) - self.contentContainerNode.addSubnode(self.historyNodeContainer) + self.contentContainerNode.contentNode.addSubnode(self.backgroundNode) + self.contentContainerNode.contentNode.addSubnode(self.historyNodeContainer) if let navigationBar = self.navigationBar { - self.contentContainerNode.addSubnode(navigationBar) + self.contentContainerNode.contentNode.addSubnode(navigationBar) } self.inputPanelContainerNode.expansionUpdated = { [weak self] transition in @@ -817,9 +882,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.inputPanelBackgroundNode.addSubnode(self.inputPanelBottomBackgroundSeparatorNode) self.addSubnode(self.messageTransitionNode) - self.contentContainerNode.addSubnode(self.navigateButtons) + self.contentContainerNode.contentNode.addSubnode(self.navigateButtons) self.addSubnode(self.presentationContextMarker) - self.contentContainerNode.addSubnode(self.contentDimNode) + self.contentContainerNode.contentNode.addSubnode(self.contentDimNode) self.navigationBar?.additionalContentNode.addSubnode(self.titleAccessoryPanelContainer) @@ -1004,9 +1069,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.emptyNode = emptyNode if let inlineSearchResultsView = self.inlineSearchResults?.view { - self.contentContainerNode.view.insertSubview(emptyNode.view, belowSubview: inlineSearchResultsView) + self.contentContainerNode.contentNode.view.insertSubview(emptyNode.view, belowSubview: inlineSearchResultsView) } else { - self.contentContainerNode.insertSubnode(emptyNode, aboveSubnode: self.historyNodeContainer) + self.contentContainerNode.contentNode.insertSubnode(emptyNode, aboveSubnode: self.historyNodeContainer) } if let (size, insets) = self.validEmptyNodeLayout { @@ -1081,12 +1146,10 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } - if let historyNodeContainer = self.historyNodeContainer as? HistoryNodeContainer { - let isSecret = self.chatPresentationInterfaceState.copyProtectionEnabled || self.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat - if historyNodeContainer.isSecret != isSecret { - historyNodeContainer.isSecret = isSecret - setLayerDisableScreenshots(self.titleAccessoryPanelContainer.layer, isSecret) - } + let isSecret = self.chatPresentationInterfaceState.copyProtectionEnabled || self.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat + if self.historyNodeContainer.isSecret != isSecret { + self.historyNodeContainer.isSecret = isSecret + setLayerDisableScreenshots(self.titleAccessoryPanelContainer.layer, isSecret) } var previousListBottomInset: CGFloat? @@ -1097,6 +1160,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.messageTransitionNode.frame = CGRect(origin: CGPoint(), size: layout.size) self.contentContainerNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.contentContainerNode.update(size: layout.size, transition: transition) let isOverlay: Bool switch self.chatPresentationInterfaceState.mode { @@ -1239,10 +1303,10 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { if let containerNode = self.containerNode { self.containerNode = nil containerNode.removeFromSupernode() - self.contentContainerNode.insertSubnode(self.backgroundNode, at: 0) - self.contentContainerNode.insertSubnode(self.historyNodeContainer, aboveSubnode: self.backgroundNode) + self.contentContainerNode.contentNode.insertSubnode(self.backgroundNode, at: 0) + self.contentContainerNode.contentNode.insertSubnode(self.historyNodeContainer, aboveSubnode: self.backgroundNode) if let restrictedNode = self.restrictedNode { - self.contentContainerNode.insertSubnode(restrictedNode, aboveSubnode: self.historyNodeContainer) + self.contentContainerNode.contentNode.insertSubnode(restrictedNode, aboveSubnode: self.historyNodeContainer) } self.navigationBar?.isHidden = false } @@ -1392,7 +1456,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { if self.chatImportStatusPanel != importStatusPanelNode { dismissedImportStatusPanelNode = self.chatImportStatusPanel self.chatImportStatusPanel = importStatusPanelNode - self.contentContainerNode.addSubnode(importStatusPanelNode) + self.contentContainerNode.contentNode.addSubnode(importStatusPanelNode) } importStatusPanelHeight = importStatusPanelNode.update(context: self.context, progress: CGFloat(importState.progress), presentationData: ChatPresentationData(theme: ChatPresentationThemeData(theme: self.chatPresentationInterfaceState.theme, wallpaper: self.chatPresentationInterfaceState.chatWallpaper), fontSize: self.chatPresentationInterfaceState.fontSize, strings: self.chatPresentationInterfaceState.strings, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, nameDisplayOrder: self.chatPresentationInterfaceState.nameDisplayOrder, disableAnimations: false, largeEmoji: false, chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0.0, auxiliaryRadius: 0.0, mergeBubbleCorners: false)), width: layout.size.width) @@ -1732,6 +1796,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { transition.updateBounds(node: self.historyNodeContainer, bounds: contentBounds) transition.updatePosition(node: self.historyNodeContainer, position: contentBounds.center) + self.historyNodeContainer.update(size: contentBounds.size, transition: transition) transition.updateBounds(node: self.historyNode, bounds: CGRect(origin: CGPoint(), size: contentBounds.size)) transition.updatePosition(node: self.historyNode, position: CGPoint(x: contentBounds.size.width / 2.0, y: contentBounds.size.height / 2.0)) @@ -1779,7 +1844,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { dismissedOverlayContextPanelNode = self.overlayContextPanelNode self.overlayContextPanelNode = overlayContextPanelNode - self.contentContainerNode.addSubnode(overlayContextPanelNode) + self.contentContainerNode.contentNode.addSubnode(overlayContextPanelNode) immediatelyLayoutOverlayContextPanelAndAnimateAppearance = true } } else if let overlayContextPanelNode = self.overlayContextPanelNode { @@ -1999,7 +2064,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { expandedInputDimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) expandedInputDimNode.alpha = 0.0 self.expandedInputDimNode = expandedInputDimNode - self.contentContainerNode.insertSubnode(expandedInputDimNode, aboveSubnode: self.historyNodeContainer) + self.contentContainerNode.contentNode.insertSubnode(expandedInputDimNode, aboveSubnode: self.historyNodeContainer) transition.updateAlpha(node: expandedInputDimNode, alpha: 1.0) expandedInputDimNode.frame = exandedFrame transition.animatePositionAdditive(node: expandedInputDimNode, offset: CGPoint(x: 0.0, y: previousInputPanelOrigin.y - inputPanelOrigin)) @@ -2831,9 +2896,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.skippedShowSearchResultsAsListAnimationOnce = true inlineSearchResultsView.layer.allowsGroupOpacity = true if let emptyNode = self.emptyNode { - self.contentContainerNode.view.insertSubview(inlineSearchResultsView, aboveSubview: emptyNode.view) + self.contentContainerNode.contentNode.view.insertSubview(inlineSearchResultsView, aboveSubview: emptyNode.view) } else { - self.contentContainerNode.view.insertSubview(inlineSearchResultsView, aboveSubview: self.historyNodeContainer.view) + self.contentContainerNode.contentNode.view.insertSubview(inlineSearchResultsView, aboveSubview: self.historyNodeContainer.view) } } inlineSearchResultsTransition.setFrame(view: inlineSearchResultsView, frame: CGRect(origin: CGPoint(), size: layout.size)) @@ -3269,15 +3334,20 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { if recognizer.state == .ended { - self.dismissInput() + self.dismissInput(view: self.view, location: recognizer.location(in: self.contentContainerNode.view)) } } - func dismissInput() { + func dismissInput(view: UIView? = nil, location: CGPoint? = nil) { if let _ = self.chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState { return } + if let view, let location { + self.contentContainerNode.triggerRipple(at: self.contentContainerNode.view.convert(location, from: view)) + self.historyNodeContainer.triggerRipple(at: self.historyNodeContainer.view.convert(location, from: view)) + } + switch self.chatPresentationInterfaceState.inputMode { case .none: break @@ -3739,7 +3809,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { let dropDimNode = ASDisplayNode() dropDimNode.backgroundColor = self.chatPresentationInterfaceState.theme.chatList.backgroundColor.withAlphaComponent(0.35) self.dropDimNode = dropDimNode - self.contentContainerNode.addSubnode(dropDimNode) + self.contentContainerNode.contentNode.addSubnode(dropDimNode) if let (layout, _) = self.validLayout { dropDimNode.frame = CGRect(origin: CGPoint(), size: layout.size) dropDimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index 184ee1c503..c731e46943 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -84,7 +84,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, navigateToMessage: { _, _, _ in }, navigateToMessageStandalone: { _ in }, navigateToThreadMessage: { _, _, _ in - }, tapMessage: nil, clickThroughMessage: { + }, tapMessage: nil, clickThroughMessage: { _, _ in }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index abc647f4f6..25a88f76b4 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -69,6 +69,7 @@ import StarsPurchaseScreen import StarsTransferScreen import StarsTransactionScreen import StarsWithdrawalScreen +import MiniAppListScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -1701,7 +1702,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { return presentAddMembersImpl(context: context, updatedPresentationData: updatedPresentationData, parentController: parentController, groupPeer: groupPeer, selectAddMemberDisposable: selectAddMemberDisposable, addMemberDisposable: addMemberDisposable) } - public func makeChatMessagePreviewItem(context: AccountContext, messages: [Message], theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder, forcedResourceStatus: FileMediaResourceStatus?, tapMessage: ((Message) -> Void)?, clickThroughMessage: (() -> Void)? = nil, backgroundNode: ASDisplayNode?, availableReactions: AvailableReactions?, accountPeer: Peer?, isCentered: Bool, isPreview: Bool, isStandalone: Bool) -> ListViewItem { + public func makeChatMessagePreviewItem(context: AccountContext, messages: [Message], theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder, forcedResourceStatus: FileMediaResourceStatus?, tapMessage: ((Message) -> Void)?, clickThroughMessage: ((UIView?, CGPoint?) -> Void)? = nil, backgroundNode: ASDisplayNode?, availableReactions: AvailableReactions?, accountPeer: Peer?, isCentered: Bool, isPreview: Bool, isStandalone: Bool) -> ListViewItem { let controllerInteraction: ChatControllerInteraction controllerInteraction = ChatControllerInteraction(openMessage: { _, _ in @@ -1711,8 +1712,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, navigateToThreadMessage: { _, _, _ in }, tapMessage: { message in tapMessage?(message) - }, clickThroughMessage: { - clickThroughMessage?() + }, clickThroughMessage: { view, location in + clickThroughMessage?(view, location) }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, requestMessageActionCallback: { _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, openMessageShareMenu: { _ in @@ -2761,6 +2762,14 @@ public final class SharedAccountContextImpl: SharedAccountContext { return StarsTransactionScreen(context: context, subject: .gift(message), action: {}) } + public func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal { + return MiniAppListScreen.initialData(context: context) + } + + public func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController { + return MiniAppListScreen(context: context, initialData: initialData as! MiniAppListScreen.InitialData) + } + public func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool) { openWebAppImpl(context: context, parentController: parentController, updatedPresentationData: updatedPresentationData, peer: peer, threadId: threadId, buttonText: buttonText, url: url, simple: simple, source: source, skipTermsOfService: skipTermsOfService) } diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index cab2dfccfe..e0ab310335 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -393,7 +393,7 @@ public final class TelegramRootController: NavigationController, TelegramRootCon return nil case let .peer(id): return id - case let .botPreview(id): + case let .botPreview(id, _): return id } } diff --git a/submodules/TranslateUI/Sources/LocalizationListItem.swift b/submodules/TranslateUI/Sources/LocalizationListItem.swift index 1ee42bf8e3..dc60b5f59d 100644 --- a/submodules/TranslateUI/Sources/LocalizationListItem.swift +++ b/submodules/TranslateUI/Sources/LocalizationListItem.swift @@ -37,10 +37,10 @@ public class LocalizationListItem: ListViewItem, ItemListItem { public let sectionId: ItemListSectionId let alwaysPlain: Bool let action: () -> Void - let setItemWithRevealedOptions: (String?, String?) -> Void - let removeItem: (String) -> Void + let setItemWithRevealedOptions: ((String?, String?) -> Void)? + let removeItem: ((String) -> Void)? - public init(presentationData: ItemListPresentationData, id: String, title: String, subtitle: String, checked: Bool, activity: Bool, loading: Bool, editing: LocalizationListItemEditing, enabled: Bool = true, sectionId: ItemListSectionId, alwaysPlain: Bool, action: @escaping () -> Void, setItemWithRevealedOptions: @escaping (String?, String?) -> Void, removeItem: @escaping (String) -> Void) { + public init(presentationData: ItemListPresentationData, id: String, title: String, subtitle: String, checked: Bool, activity: Bool, loading: Bool, editing: LocalizationListItemEditing, enabled: Bool = true, sectionId: ItemListSectionId, alwaysPlain: Bool, action: @escaping () -> Void, setItemWithRevealedOptions: ((String?, String?) -> Void)?, removeItem: ((String) -> Void)?) { self.presentationData = presentationData self.id = id self.title = title @@ -368,7 +368,7 @@ class LocalizationListItemNode: ItemListRevealOptionsItemNode { strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset) - if item.editing.editable { + if item.editing.editable, item.removeItem != nil { strongSelf.setRevealOptions((left: [], right: [ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)])) } else { strongSelf.setRevealOptions((left: [], right: [])) @@ -491,13 +491,13 @@ class LocalizationListItemNode: ItemListRevealOptionsItemNode { override func revealOptionsInteractivelyOpened() { if let item = self.item { - item.setItemWithRevealedOptions(item.id, nil) + item.setItemWithRevealedOptions?(item.id, nil) } } override func revealOptionsInteractivelyClosed() { if let item = self.item { - item.setItemWithRevealedOptions(nil, item.id) + item.setItemWithRevealedOptions?(nil, item.id) } } @@ -506,7 +506,7 @@ class LocalizationListItemNode: ItemListRevealOptionsItemNode { self.revealOptionsInteractivelyClosed() if let item = self.item { - item.removeItem(item.id) + item.removeItem?(item.id) } } }