diff --git a/.gitmodules b/.gitmodules index c44a0bbf39..e66c7387a0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,3 +32,6 @@ url=../tgcalls.git [submodule "third-party/td/td"] path = third-party/td/td url = https://github.com/tdlib/td +[submodule "third-party/XcodeGen"] + path = third-party/XcodeGen + url = https://github.com/yonaskolb/XcodeGen.git diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index f351ed9b36..7ea53add07 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1137,6 +1137,7 @@ public protocol SharedAccountContext: AnyObject { func makeHashtagSearchController(context: AccountContext, peer: EnginePeer?, query: String, stories: Bool, forceDark: Bool) -> ViewController func makeStorySearchController(context: AccountContext, scope: StorySearchControllerScope, listContext: SearchStoryListContext?) -> ViewController func makeMyStoriesController(context: AccountContext, isArchive: Bool) -> ViewController + func makeStorySelectionController(context: AccountContext, peerId: EnginePeer.Id, completion: @escaping ([EngineStoryItem]) -> Void) -> ViewController func makeArchiveSettingsController(context: AccountContext) -> ViewController func makeFilterSettingsController(context: AccountContext, modal: Bool, scrollToTags: Bool, dismissed: (() -> Void)?) -> ViewController func makeBusinessSetupScreen(context: AccountContext) -> ViewController diff --git a/submodules/PromptUI/Sources/PromptController.swift b/submodules/PromptUI/Sources/PromptController.swift index 06dee32735..3d0eca125a 100644 --- a/submodules/PromptUI/Sources/PromptController.swift +++ b/submodules/PromptUI/Sources/PromptController.swift @@ -165,8 +165,10 @@ private final class PromptAlertContentNode: AlertContentNode { private let strings: PresentationStrings private let text: String private let titleFont: PromptControllerTitleFont + private let subtitle: String? private let textNode: ASTextNode + private let subtitleNode: ASTextNode? let inputFieldNode: PromptInputFieldNode private let actionNodesSeparator: ASDisplayNode @@ -189,14 +191,23 @@ private final class PromptAlertContentNode: AlertContentNode { return self.isUserInteractionEnabled } - init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, actions: [TextAlertAction], text: String, titleFont: PromptControllerTitleFont, value: String?, placeholder: String?, characterLimit: Int) { + init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, actions: [TextAlertAction], text: String, titleFont: PromptControllerTitleFont, subtitle: String?, value: String?, placeholder: String?, characterLimit: Int) { self.strings = strings self.text = text self.titleFont = titleFont + self.subtitle = subtitle self.textNode = ASTextNode() self.textNode.maximumNumberOfLines = 2 + if subtitle != nil { + let subtitleNode = ASTextNode() + subtitleNode.maximumNumberOfLines = 0 + self.subtitleNode = subtitleNode + } else { + self.subtitleNode = nil + } + self.inputFieldNode = PromptInputFieldNode(theme: ptheme, placeholder: placeholder ?? "", characterLimit: characterLimit) self.inputFieldNode.text = value ?? "" @@ -220,6 +231,9 @@ private final class PromptAlertContentNode: AlertContentNode { super.init() self.addSubnode(self.textNode) + if let subtitleNode = self.subtitleNode { + self.addSubnode(subtitleNode) + } self.addSubnode(self.inputFieldNode) @@ -268,6 +282,10 @@ private final class PromptAlertContentNode: AlertContentNode { titleFontValue = Font.semibold(17.0) } self.textNode.attributedText = NSAttributedString(string: self.text, font: titleFontValue, textColor: theme.primaryColor, paragraphAlignment: .center) + + if let subtitle = self.subtitle, let subtitleNode = self.subtitleNode { + subtitleNode.attributedText = NSAttributedString(string: subtitle, font: Font.regular(13.0), textColor: theme.primaryColor, paragraphAlignment: .center) + } self.actionNodesSeparator.backgroundColor = theme.separatorColor for actionNode in self.actionNodes { @@ -302,6 +320,14 @@ private final class PromptAlertContentNode: AlertContentNode { transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize)) origin.y += textSize.height + 6.0 + spacing + var subtitleSize: CGSize? + if let subtitleNode { + let subtitleSizeValue = subtitleNode.measure(measureSize) + subtitleSize = subtitleSizeValue + transition.updateFrame(node: subtitleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - subtitleSizeValue.width) / 2.0), y: origin.y), size: subtitleSizeValue)) + origin.y += subtitleSizeValue.height + 6.0 + spacing + } + let actionButtonHeight: CGFloat = 44.0 var minActionsWidth: CGFloat = 0.0 let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) @@ -324,6 +350,9 @@ private final class PromptAlertContentNode: AlertContentNode { let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 9.0, right: 18.0) var contentWidth = max(titleSize.width, minActionsWidth) + if let subtitleSize { + contentWidth = max(contentWidth, subtitleSize.width) + } contentWidth = max(contentWidth, 234.0) var actionsHeight: CGFloat = 0.0 @@ -342,7 +371,10 @@ private final class PromptAlertContentNode: AlertContentNode { transition.updateFrame(node: self.inputFieldNode, frame: CGRect(x: 0.0, y: origin.y, width: resultWidth, height: inputFieldHeight)) transition.updateAlpha(node: self.inputFieldNode, alpha: inputHeight > 0.0 ? 1.0 : 0.0) - let resultSize = CGSize(width: resultWidth, height: titleSize.height + textSize.height + spacing + inputHeight + actionsHeight + insets.top + insets.bottom) + var resultSize = CGSize(width: resultWidth, height: titleSize.height + textSize.height + spacing + inputHeight + actionsHeight + insets.top + insets.bottom) + if let subtitleSize { + resultSize.height += subtitleSize.height + spacing + } transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) @@ -407,7 +439,7 @@ public enum PromptControllerTitleFont { case bold } -public func promptController(sharedContext: SharedAccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, text: String, titleFont: PromptControllerTitleFont = .regular, value: String?, placeholder: String? = nil, characterLimit: Int = 1000, apply: @escaping (String?) -> Void) -> AlertController { +public func promptController(sharedContext: SharedAccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, text: String, titleFont: PromptControllerTitleFont = .regular, subtitle: String? = nil, value: String?, placeholder: String? = nil, characterLimit: Int = 1000, apply: @escaping (String?) -> Void) -> AlertController { let presentationData = updatedPresentationData?.initial ?? sharedContext.currentPresentationData.with { $0 } var dismissImpl: ((Bool) -> Void)? @@ -421,7 +453,7 @@ public func promptController(sharedContext: SharedAccountContext, updatedPresent applyImpl?() })] - let contentNode = PromptAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: presentationData.theme, strings: presentationData.strings, actions: actions, text: text, titleFont: titleFont, value: value, placeholder: placeholder, characterLimit: characterLimit) + let contentNode = PromptAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: presentationData.theme, strings: presentationData.strings, actions: actions, text: text, titleFont: titleFont, subtitle: subtitle, value: value, placeholder: placeholder, characterLimit: characterLimit) contentNode.complete = { dismissImpl?(true) applyImpl?() diff --git a/submodules/StatisticsUI/Sources/ChannelStatsController.swift b/submodules/StatisticsUI/Sources/ChannelStatsController.swift index ff37e68268..dd808637c0 100644 --- a/submodules/StatisticsUI/Sources/ChannelStatsController.swift +++ b/submodules/StatisticsUI/Sources/ChannelStatsController.swift @@ -2127,7 +2127,7 @@ public func channelStatsController( } messagesPromise.set(.single(nil) |> then(messageView)) - let storyList = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + let storyList = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) storyList.loadMore() storiesPromise.set( .single(nil) diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 5cc3a6bf37..1c5bc65eff 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -943,7 +943,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[2109703795] = { return Api.SponsoredMessage.parse_sponsoredMessage($0) } dict[1124938064] = { return Api.SponsoredMessageReportOption.parse_sponsoredMessageReportOption($0) } dict[-963180333] = { return Api.SponsoredPeer.parse_sponsoredPeer($0) } - dict[2139438098] = { return Api.StarGift.parse_starGift($0) } + dict[12386139] = { return Api.StarGift.parse_starGift($0) } dict[-164136786] = { return Api.StarGift.parse_starGiftUnique($0) } dict[-650279524] = { return Api.StarGiftAttribute.parse_starGiftAttributeBackdrop($0) } dict[970559507] = { return Api.StarGiftAttribute.parse_starGiftAttributeModel($0) } diff --git a/submodules/TelegramApi/Sources/Api24.swift b/submodules/TelegramApi/Sources/Api24.swift index 50f5d74a4f..204da6c177 100644 --- a/submodules/TelegramApi/Sources/Api24.swift +++ b/submodules/TelegramApi/Sources/Api24.swift @@ -636,14 +636,14 @@ public extension Api { } public extension Api { enum StarGift: TypeConstructorDescription { - case starGift(flags: Int32, id: Int64, sticker: Api.Document, stars: Int64, availabilityRemains: Int32?, availabilityTotal: Int32?, availabilityResale: Int64?, convertStars: Int64, firstSaleDate: Int32?, lastSaleDate: Int32?, upgradeStars: Int64?, resellMinStars: Int64?, title: String?, releasedBy: Api.Peer?) + case starGift(flags: Int32, id: Int64, sticker: Api.Document, stars: Int64, availabilityRemains: Int32?, availabilityTotal: Int32?, availabilityResale: Int64?, convertStars: Int64, firstSaleDate: Int32?, lastSaleDate: Int32?, upgradeStars: Int64?, resellMinStars: Int64?, title: String?, releasedBy: Api.Peer?, perUserTotal: Int32?, perUserRemains: Int32?) case starGiftUnique(flags: Int32, id: Int64, title: String, slug: String, num: Int32, ownerId: Api.Peer?, ownerName: String?, ownerAddress: String?, attributes: [Api.StarGiftAttribute], availabilityIssued: Int32, availabilityTotal: Int32, giftAddress: String?, resellStars: Int64?, releasedBy: Api.Peer?) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .starGift(let flags, let id, let sticker, let stars, let availabilityRemains, let availabilityTotal, let availabilityResale, let convertStars, let firstSaleDate, let lastSaleDate, let upgradeStars, let resellMinStars, let title, let releasedBy): + case .starGift(let flags, let id, let sticker, let stars, let availabilityRemains, let availabilityTotal, let availabilityResale, let convertStars, let firstSaleDate, let lastSaleDate, let upgradeStars, let resellMinStars, let title, let releasedBy, let perUserTotal, let perUserRemains): if boxed { - buffer.appendInt32(2139438098) + buffer.appendInt32(12386139) } serializeInt32(flags, buffer: buffer, boxed: false) serializeInt64(id, buffer: buffer, boxed: false) @@ -659,6 +659,8 @@ public extension Api { if Int(flags) & Int(1 << 4) != 0 {serializeInt64(resellMinStars!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 5) != 0 {serializeString(title!, buffer: buffer, boxed: false)} if Int(flags) & Int(1 << 6) != 0 {releasedBy!.serialize(buffer, true)} + if Int(flags) & Int(1 << 8) != 0 {serializeInt32(perUserTotal!, buffer: buffer, boxed: false)} + if Int(flags) & Int(1 << 8) != 0 {serializeInt32(perUserRemains!, buffer: buffer, boxed: false)} break case .starGiftUnique(let flags, let id, let title, let slug, let num, let ownerId, let ownerName, let ownerAddress, let attributes, let availabilityIssued, let availabilityTotal, let giftAddress, let resellStars, let releasedBy): if boxed { @@ -688,8 +690,8 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .starGift(let flags, let id, let sticker, let stars, let availabilityRemains, let availabilityTotal, let availabilityResale, let convertStars, let firstSaleDate, let lastSaleDate, let upgradeStars, let resellMinStars, let title, let releasedBy): - return ("starGift", [("flags", flags as Any), ("id", id as Any), ("sticker", sticker as Any), ("stars", stars as Any), ("availabilityRemains", availabilityRemains as Any), ("availabilityTotal", availabilityTotal as Any), ("availabilityResale", availabilityResale as Any), ("convertStars", convertStars as Any), ("firstSaleDate", firstSaleDate as Any), ("lastSaleDate", lastSaleDate as Any), ("upgradeStars", upgradeStars as Any), ("resellMinStars", resellMinStars as Any), ("title", title as Any), ("releasedBy", releasedBy as Any)]) + case .starGift(let flags, let id, let sticker, let stars, let availabilityRemains, let availabilityTotal, let availabilityResale, let convertStars, let firstSaleDate, let lastSaleDate, let upgradeStars, let resellMinStars, let title, let releasedBy, let perUserTotal, let perUserRemains): + return ("starGift", [("flags", flags as Any), ("id", id as Any), ("sticker", sticker as Any), ("stars", stars as Any), ("availabilityRemains", availabilityRemains as Any), ("availabilityTotal", availabilityTotal as Any), ("availabilityResale", availabilityResale as Any), ("convertStars", convertStars as Any), ("firstSaleDate", firstSaleDate as Any), ("lastSaleDate", lastSaleDate as Any), ("upgradeStars", upgradeStars as Any), ("resellMinStars", resellMinStars as Any), ("title", title as Any), ("releasedBy", releasedBy as Any), ("perUserTotal", perUserTotal as Any), ("perUserRemains", perUserRemains as Any)]) case .starGiftUnique(let flags, let id, let title, let slug, let num, let ownerId, let ownerName, let ownerAddress, let attributes, let availabilityIssued, let availabilityTotal, let giftAddress, let resellStars, let releasedBy): return ("starGiftUnique", [("flags", flags as Any), ("id", id as Any), ("title", title as Any), ("slug", slug as Any), ("num", num as Any), ("ownerId", ownerId as Any), ("ownerName", ownerName as Any), ("ownerAddress", ownerAddress as Any), ("attributes", attributes as Any), ("availabilityIssued", availabilityIssued as Any), ("availabilityTotal", availabilityTotal as Any), ("giftAddress", giftAddress as Any), ("resellStars", resellStars as Any), ("releasedBy", releasedBy as Any)]) } @@ -728,6 +730,10 @@ public extension Api { if Int(_1!) & Int(1 << 6) != 0 {if let signature = reader.readInt32() { _14 = Api.parse(reader, signature: signature) as? Api.Peer } } + var _15: Int32? + if Int(_1!) & Int(1 << 8) != 0 {_15 = reader.readInt32() } + var _16: Int32? + if Int(_1!) & Int(1 << 8) != 0 {_16 = reader.readInt32() } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil @@ -742,8 +748,10 @@ public extension Api { let _c12 = (Int(_1!) & Int(1 << 4) == 0) || _12 != nil let _c13 = (Int(_1!) & Int(1 << 5) == 0) || _13 != nil let _c14 = (Int(_1!) & Int(1 << 6) == 0) || _14 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 { - return Api.StarGift.starGift(flags: _1!, id: _2!, sticker: _3!, stars: _4!, availabilityRemains: _5, availabilityTotal: _6, availabilityResale: _7, convertStars: _8!, firstSaleDate: _9, lastSaleDate: _10, upgradeStars: _11, resellMinStars: _12, title: _13, releasedBy: _14) + let _c15 = (Int(_1!) & Int(1 << 8) == 0) || _15 != nil + let _c16 = (Int(_1!) & Int(1 << 8) == 0) || _16 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 && _c8 && _c9 && _c10 && _c11 && _c12 && _c13 && _c14 && _c15 && _c16 { + return Api.StarGift.starGift(flags: _1!, id: _2!, sticker: _3!, stars: _4!, availabilityRemains: _5, availabilityTotal: _6, availabilityResale: _7, convertStars: _8!, firstSaleDate: _9, lastSaleDate: _10, upgradeStars: _11, resellMinStars: _12, title: _13, releasedBy: _14, perUserTotal: _15, perUserRemains: _16) } else { return nil diff --git a/submodules/TelegramCore/Sources/State/Serialization.swift b/submodules/TelegramCore/Sources/State/Serialization.swift index ab9b2a4f2e..ddd61c801e 100644 --- a/submodules/TelegramCore/Sources/State/Serialization.swift +++ b/submodules/TelegramCore/Sources/State/Serialization.swift @@ -210,7 +210,7 @@ public class BoxedMessage: NSObject { public class Serialization: NSObject, MTSerialization { public func currentLayer() -> UInt { - return 207 + return 210 } public func parseMessage(_ data: Data!) -> Any! { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index 97bd70c9af..dee5ba492b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -531,11 +531,13 @@ private final class CachedPeerStoryListHead: Codable { let items: [Stories.StoredItem] let pinnedIds: [Int32] let totalCount: Int32 + let folders: [StoryListContext.State.Folder] - init(items: [Stories.StoredItem], pinnedIds: [Int32], totalCount: Int32) { + init(items: [Stories.StoredItem], pinnedIds: [Int32], totalCount: Int32, folders: [StoryListContext.State.Folder]) { self.items = items self.pinnedIds = pinnedIds self.totalCount = totalCount + self.folders = folders } } @@ -578,9 +580,20 @@ public struct StoryListContextState: Equatable { } } + public struct Folder: Equatable, Codable { + public let id: Int64 + public let title: String + + public init(id: Int64, title: String) { + self.id = id + self.title = title + } + } + public var peerReference: PeerReference? public var items: [Item] public var availableLanguages: [Language] + public var availableFolders: [Folder] public var pinnedIds: [Int32] public var totalCount: Int public var loadMoreToken: AnyHashable? @@ -592,6 +605,7 @@ public struct StoryListContextState: Equatable { peerReference: PeerReference?, items: [Item], availableLanguages: [Language], + availableFolders: [Folder], pinnedIds: [Int32], totalCount: Int, loadMoreToken: AnyHashable?, @@ -603,6 +617,7 @@ public struct StoryListContextState: Equatable { self.peerReference = peerReference self.items = items self.availableLanguages = availableLanguages + self.availableFolders = availableFolders self.pinnedIds = pinnedIds self.totalCount = totalCount self.loadMoreToken = loadMoreToken @@ -621,12 +636,13 @@ public protocol StoryListContext: AnyObject { func loadMore(completion: (() -> Void)?) } -public final class PeerStoryListContext: StoryListContext { +public final class PeerStoryListContext: StoryListContext { private final class Impl { private let queue: Queue private let account: Account private let peerId: EnginePeer.Id private let isArchived: Bool + private let folderId: Int64? private let statePromise = Promise() private var stateValue: State { @@ -645,22 +661,40 @@ public final class PeerStoryListContext: StoryListContext { private var completionCallbacksByToken: [AnyHashable: [() -> Void]] = [:] - init(queue: Queue, account: Account, peerId: EnginePeer.Id, isArchived: Bool) { + init(queue: Queue, account: Account, peerId: EnginePeer.Id, isArchived: Bool, folderId: Int64?) { self.queue = queue self.account = account self.peerId = peerId self.isArchived = isArchived + self.folderId = folderId - self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], availableFolders: [], 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 _ = (account.postbox.transaction { transaction -> (PeerReference?, [State.Item], [Int32], Int, [MediaId: TelegramMediaFile], [StoryListContext.State.Folder], Bool) in let key = ValueBoxKey(length: 8 + 1) key.setInt64(0, value: peerId.toInt64()) key.setInt8(8, value: isArchived ? 1 : 0) - let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) - guard let cached = cached else { - return (nil, [], [], 0, [:], false) + let cachedMain = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) + guard let cachedMain else { + return (nil, [], [], 0, [:], [], false) } + + let cached: CachedPeerStoryListHead + if let folderId { + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: isArchived ? 1 : 0) + key.setInt64(8 + 1, value: folderId) + let cachedFolder = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) + if let cachedFolder { + cached = cachedFolder + } else { + return (nil, [], [], 0, [:], cachedMain.folders, false) + } + } else { + cached = cachedMain + } + var items: [State.Item] = [] var allEntityFiles: [MediaId: TelegramMediaFile] = [:] for storedItem in cached.items { @@ -734,28 +768,30 @@ public final class PeerStoryListContext: StoryListContext { let peerReference = transaction.getPeer(peerId).flatMap(PeerReference.init) - return (peerReference, items, cached.pinnedIds, Int(cached.totalCount), allEntityFiles, true) + return (peerReference, items, cached.pinnedIds, Int(cached.totalCount), allEntityFiles, cached.folders, true) } - |> deliverOn(self.queue)).start(next: { [weak self] peerReference, items, pinnedIds, totalCount, allEntityFiles, hasCache in - guard let `self` = self else { + |> deliverOn(self.queue)).start(next: { [weak self] peerReference, items, pinnedIds, totalCount, allEntityFiles, folders, hasCache in + guard let self else { return } - 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) - - if let lhsPinned, let rhsPinned { - if lhsPinned != rhsPinned { - return lhsPinned < rhsPinned + var updatedState = State(peerReference: peerReference, items: items, availableLanguages: [], availableFolders: folders, pinnedIds: pinnedIds, totalCount: totalCount, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: hasCache, allEntityFiles: allEntityFiles, isLoading: false) + if self.folderId == nil { + 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) + + if let lhsPinned, let rhsPinned { + if lhsPinned != rhsPinned { + return lhsPinned < rhsPinned + } + } else if (lhsPinned == nil) != (rhsPinned == nil) { + return lhsPinned != nil } - } else if (lhsPinned == nil) != (rhsPinned == nil) { - return lhsPinned != nil - } - - return lhs.storyItem.timestamp > rhs.storyItem.timestamp - }) + + return lhs.storyItem.timestamp > rhs.storyItem.timestamp + }) + } self.stateValue = updatedState self.loadMore(completion: nil) @@ -791,6 +827,8 @@ public final class PeerStoryListContext: StoryListContext { let account = self.account let accountPeerId = account.peerId let isArchived = self.isArchived + let folderId = self.folderId + let folders = self.stateValue.availableFolders self.requestDisposable = (self.account.postbox.transaction { transaction -> Api.InputPeer? in return transaction.getPeer(peerId).flatMap(apiInputPeer) } @@ -822,6 +860,73 @@ public final class PeerStoryListContext: StoryListContext { var totalCount: Int = 0 var hasMore: Bool = false + if let folderId { + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: isArchived ? 1 : 0) + key.setInt64(8 + 1, value: folderId) + + var updatedItems: [Stories.StoredItem] = [] + if let currentEntry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + updatedItems = currentEntry.items + } + + if let entry = CodableEntry(CachedPeerStoryListHead(items: updatedItems, pinnedIds: [], totalCount: Int32(updatedItems.count), folders: [])) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + + for item in updatedItems { + if case let .item(item) = item, let media = item.media { + let mappedItem = EngineStoryItem( + id: item.id, + timestamp: item.timestamp, + expirationTimestamp: item.expirationTimestamp, + media: EngineMedia(media), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), + mediaAreas: item.mediaAreas, + text: item.text, + entities: item.entities, + views: item.views.flatMap { views in + return EngineStoryItem.Views( + seenCount: views.seenCount, + reactedCount: views.reactedCount, + forwardCount: views.forwardCount, + seenPeers: views.seenPeerIds.compactMap { id -> EnginePeer? in + return transaction.getPeer(id).flatMap(EnginePeer.init) + }, + reactions: views.reactions, + hasList: views.hasList + ) + }, + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic, + isPending: false, + isCloseFriends: item.isCloseFriends, + isContacts: item.isContacts, + isSelectedContacts: item.isSelectedContacts, + isForwardingDisabled: item.isForwardingDisabled, + isEdited: item.isEdited, + isMy: item.isMy, + myReaction: item.myReaction, + forwardInfo: item.forwardInfo.flatMap { EngineStoryItem.ForwardInfo($0, transaction: transaction) }, + author: item.authorId.flatMap { transaction.getPeer($0).flatMap(EnginePeer.init) } + ) + storyItems.append(State.Item( + id: StoryId(peerId: peerId, id: mappedItem.id), + storyItem: mappedItem, + peer: nil + )) + } + } + + totalCount = storyItems.count + hasMore = false + + return (storyItems, totalCount, transaction.getPeer(peerId).flatMap(PeerReference.init), hasMore) + } + switch result { case let .stories(_, count, stories, pinnedStories, chats, users): totalCount = Int(count) @@ -883,7 +988,7 @@ public final class PeerStoryListContext: StoryListContext { let key = ValueBoxKey(length: 8 + 1) key.setInt64(0, value: peerId.toInt64()) key.setInt8(8, value: isArchived ? 1 : 0) - if let entry = CodableEntry(CachedPeerStoryListHead(items: storyItems.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: count)) { + if let entry = CodableEntry(CachedPeerStoryListHead(items: storyItems.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: count, folders: folders)) { transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) } } @@ -939,7 +1044,7 @@ public final class PeerStoryListContext: StoryListContext { } } - if self.updatesDisposable == nil { + if self.updatesDisposable == nil && self.folderId == nil { self.updatesDisposable = (self.account.stateManager.storyUpdates |> deliverOn(self.queue)).start(next: { [weak self] updates in guard let `self` = self else { @@ -1255,11 +1360,12 @@ public final class PeerStoryListContext: StoryListContext { let items = finalUpdatedState.items let pinnedIds = finalUpdatedState.pinnedIds let totalCount = finalUpdatedState.totalCount + let folders = finalUpdatedState.availableFolders let _ = (self.account.postbox.transaction { transaction -> Void in let key = ValueBoxKey(length: 8 + 1) key.setInt64(0, value: peerId.toInt64()) key.setInt8(8, value: isArchived ? 1 : 0) - if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount))) { + if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount), folders: folders)) { transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) } }).start() @@ -1269,6 +1375,242 @@ public final class PeerStoryListContext: StoryListContext { } }) } + + func addFolder(title: String) -> Int64 { + let id = Int64.random(in: Int64.min ... Int64.max) + + var state = self.stateValue + state.availableFolders.append(StoryListContextState.Folder(id: id, title: title)) + self.stateValue = state + + let peerId = self.peerId + let isArchived = self.isArchived + let items = state.items + let pinnedIds = state.pinnedIds + let totalCount = state.totalCount + let folders = state.availableFolders + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: isArchived ? 1 : 0) + if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount), folders: folders)) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + + return id + } + + func removeFolder(id: Int64) { + var state = self.stateValue + if let index = state.availableFolders.firstIndex(where: { $0.id == id }) { + state.availableFolders.remove(at: index) + } + self.stateValue = state + + let peerId = self.peerId + let isArchived = self.isArchived + let items = state.items + let pinnedIds = state.pinnedIds + let totalCount = state.totalCount + let folders = state.availableFolders + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: isArchived ? 1 : 0) + if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount), folders: folders)) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + } + + func addToFolder(id: Int64, items: [EngineStoryItem]) { + let peerId = self.peerId + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + key.setInt64(8 + 1, value: id) + + var updatedItems: [Stories.Item] = [] + if let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + updatedItems = cached.items.compactMap { item -> Stories.Item? in + switch item { + case let .item(item): + return item + case .placeholder: + return nil + } + } + } + for item in items { + let mappedItem = Stories.Item( + id: item.id, + timestamp: item.timestamp, + expirationTimestamp: item.expirationTimestamp, + media: item.media._asMedia(), + alternativeMediaList: item.alternativeMediaList.map({ $0._asMedia() }), + mediaAreas: item.mediaAreas, + text: item.text, + entities: item.entities, + views: item.views.flatMap { + return Stories.Item.Views( + seenCount: $0.seenCount, + reactedCount: $0.reactedCount, + forwardCount: $0.forwardCount, + seenPeerIds: $0.seenPeers.map { $0.id }, + reactions: $0.reactions, + hasList: $0.hasList + ) + }, + privacy: item.privacy.flatMap { + return Stories.Item.Privacy( + base: $0.base, + additionallyIncludePeers: $0.additionallyIncludePeers + ) + }, + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic, + isCloseFriends: item.isCloseFriends, + isContacts: item.isContacts, + isSelectedContacts: item.isSelectedContacts, + isForwardingDisabled: item.isForwardingDisabled, + isEdited: item.isEdited, + isMy: item.isMy, + myReaction: item.myReaction, + forwardInfo: item.forwardInfo.flatMap { + switch $0 { + case let .known(peer, storyId, isModified): + return .known(peerId: peer.id, storyId: storyId, isModified: isModified) + case let .unknown(name, isModified): + return .unknown(name: name, isModified: isModified) + } + }, + authorId: item.author?.id + ) + if !updatedItems.contains(where: { $0.id == mappedItem.id }) { + updatedItems.insert(mappedItem, at: 0) + } + } + + if let entry = CodableEntry(CachedPeerStoryListHead(items: updatedItems.map { .item($0) }, pinnedIds: [], totalCount: Int32(updatedItems.count), folders: [])) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + + if self.folderId == id { + var state = self.stateValue + for item in items { + state.items.removeAll(where: { $0.id.id == item.id }) + state.items.insert(StoryListContextState.Item( + id: StoryId(peerId: self.peerId, id: item.id), + storyItem: item, + peer: nil + ), at: 0) + } + self.stateValue = state + } + } + + func removeFromFolder(id: Int64, itemIds: [Int32]) { + let peerId = self.peerId + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + key.setInt64(8 + 1, value: id) + + var updatedItems: [Stories.Item] = [] + if let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + updatedItems = cached.items.compactMap { item -> Stories.Item? in + switch item { + case let .item(item): + return item + case .placeholder: + return nil + } + } + } + for itemId in itemIds { + if let index = updatedItems.firstIndex(where: { $0.id == itemId }) { + updatedItems.remove(at: index) + } + } + + if let entry = CodableEntry(CachedPeerStoryListHead(items: updatedItems.map { .item($0) }, pinnedIds: [], totalCount: Int32(updatedItems.count), folders: [])) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + + if self.folderId == id { + var state = self.stateValue + state.items.removeAll(where: { itemIds.contains($0.id.id) }) + self.stateValue = state + } + } + + func reorderItemsInFolder(itemIds: [Int32]) { + guard let id = self.folderId else { + return + } + + let peerId = self.peerId + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + key.setInt64(8 + 1, value: id) + + var previousItems: [Stories.Item] = [] + if let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + previousItems = cached.items.compactMap { item -> Stories.Item? in + switch item { + case let .item(item): + return item + case .placeholder: + return nil + } + } + } + + var updatedItems: [Stories.Item] = [] + for itemId in itemIds { + if let index = previousItems.firstIndex(where: { $0.id == itemId }) { + updatedItems.append(previousItems[index]) + } + } + for item in previousItems { + if !updatedItems.contains(where: { $0.id == item.id }) { + updatedItems.append(item) + } + } + + if let entry = CodableEntry(CachedPeerStoryListHead(items: updatedItems.map { .item($0) }, pinnedIds: [], totalCount: Int32(updatedItems.count), folders: [])) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + + if self.folderId == id { + var state = self.stateValue + + let previousItems = state.items + var updatedItems: [State.Item] = [] + for itemId in itemIds { + if let index = previousItems.firstIndex(where: { $0.id.id == itemId }) { + updatedItems.append(previousItems[index]) + } + } + for item in previousItems { + if !updatedItems.contains(where: { $0.id == item.id }) { + updatedItems.append(item) + } + } + + state.items = updatedItems + self.stateValue = state + } + } } public var state: Signal { @@ -1280,11 +1622,18 @@ public final class PeerStoryListContext: StoryListContext { private let queue: Queue private let impl: QueueLocalObject - public init(account: Account, peerId: EnginePeer.Id, isArchived: Bool) { + private let account: Account + public let peerId: EnginePeer.Id + public let folderId: Int64? + + public init(account: Account, peerId: EnginePeer.Id, isArchived: Bool, folderId: Int64?) { let queue = Queue.mainQueue() self.queue = queue + self.account = account + self.peerId = peerId + self.folderId = folderId self.impl = QueueLocalObject(queue: queue, generate: { - return Impl(queue: queue, account: account, peerId: peerId, isArchived: isArchived) + return Impl(queue: queue, account: account, peerId: peerId, isArchived: isArchived, folderId: folderId) }) } @@ -1293,6 +1642,111 @@ public final class PeerStoryListContext: StoryListContext { impl.loadMore(completion : completion) } } + + public func addFolder(title: String, completion: @escaping (Int64) -> Void) { + self.impl.with { impl in + completion(impl.addFolder(title: title)) + } + } + + public func removeFolder(id: Int64) { + self.impl.with { impl in + impl.removeFolder(id: id) + } + } + + public func addToFolder(id: Int64, items: [EngineStoryItem]) { + self.impl.with { impl in + impl.addToFolder(id: id, items: items) + } + } + + public func removeFromFolder(id: Int64, itemIds: [Int32]) { + self.impl.with { impl in + impl.removeFromFolder(id: id, itemIds: itemIds) + } + } + + public func reorderItemsInFolder(itemIds: [Int32]) { + self.impl.with { impl in + impl.reorderItemsInFolder(itemIds: itemIds) + } + } + + public static func folderPreviews(peerId: EnginePeer.Id, account: Account) -> Signal<(peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]), NoError> { + return account.postbox.transaction { transaction -> (peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]) in + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + + var folders: [StoryListContext.State.Folder] = [] + if let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + folders = cached.folders + } + + var result: [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)] = [] + + for folder in folders { + let key = ValueBoxKey(length: 8 + 1 + 8) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + key.setInt64(8 + 1, value: folder.id) + + var mappedItem: EngineStoryItem? + if let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) { + if let firstItem = cached.items.first, case let .item(item) = firstItem, let media = item.media { + mappedItem = EngineStoryItem( + id: item.id, + timestamp: item.timestamp, + expirationTimestamp: item.expirationTimestamp, + media: EngineMedia(media), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), + mediaAreas: item.mediaAreas, + text: item.text, + entities: item.entities, + views: item.views.flatMap { views in + return EngineStoryItem.Views( + seenCount: views.seenCount, + reactedCount: views.reactedCount, + forwardCount: views.forwardCount, + seenPeers: views.seenPeerIds.compactMap { id -> EnginePeer? in + return transaction.getPeer(id).flatMap(EnginePeer.init) + }, + reactions: views.reactions, + hasList: views.hasList + ) + }, + privacy: item.privacy.flatMap(EngineStoryPrivacy.init), + isPinned: item.isPinned, + isExpired: item.isExpired, + isPublic: item.isPublic, + isPending: false, + isCloseFriends: item.isCloseFriends, + isContacts: item.isContacts, + isSelectedContacts: item.isSelectedContacts, + isForwardingDisabled: item.isForwardingDisabled, + isEdited: item.isEdited, + isMy: item.isMy, + myReaction: item.myReaction, + forwardInfo: item.forwardInfo.flatMap { EngineStoryItem.ForwardInfo($0, transaction: transaction) }, + author: item.authorId.flatMap { transaction.getPeer($0).flatMap(EnginePeer.init) } + ) + } + } + + result.append((folder, mappedItem)) + } + + let peerReference: PeerReference + if let peer = transaction.getPeer(peerId) { + peerReference = PeerReference(peer) ?? .user(id: peerId.id._internalGetInt64Value(), accessHash: 0) + } else { + peerReference = .user(id: peerId.id._internalGetInt64Value(), accessHash: 0) + } + + return (peerReference, result) + } + } } public final class SearchStoryListContext: StoryListContext { @@ -1332,7 +1786,7 @@ public final class SearchStoryListContext: StoryListContext { self.account = account self.source = source - self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(""), isCached: false, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], availableFolders: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(""), isCached: false, hasCache: false, allEntityFiles: [:], isLoading: false) self.statePromise.set(.single(self.stateValue)) self.loadMore(completion: nil) @@ -2149,7 +2603,7 @@ public final class BotPreviewStoryListContext: StoryListContext { self.isArchived = isArchived - self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) + self.stateValue = State(peerReference: nil, items: [], availableLanguages: [], availableFolders: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0 as Int), isCached: true, hasCache: false, allEntityFiles: [:], isLoading: false) let localStateKey: PostboxViewKey = .storiesState(key: .local) @@ -2166,6 +2620,7 @@ public final class BotPreviewStoryListContext: StoryListContext { peerReference: peer.flatMap(PeerReference.init), items: [], availableLanguages: [], + availableFolders: [], pinnedIds: [], totalCount: 0, loadMoreToken: AnyHashable(0), @@ -2313,6 +2768,7 @@ public final class BotPreviewStoryListContext: StoryListContext { peerReference: (peer?._asPeer()).flatMap(PeerReference.init), items: items, availableLanguages: availableLanguages, + availableFolders: [], pinnedIds: [], totalCount: items.count, loadMoreToken: nil, @@ -2413,6 +2869,7 @@ public final class BotPreviewStoryListContext: StoryListContext { peerReference: PeerReference(peer), items: items, availableLanguages: [], + availableFolders: [], pinnedIds: [], totalCount: items.count, loadMoreToken: nil, @@ -2592,6 +3049,7 @@ public final class BotPreviewStoryListContext: StoryListContext { peerReference: self.stateValue.peerReference, items: items, availableLanguages: [], + availableFolders: [], pinnedIds: [], totalCount: items.count, loadMoreToken: nil, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift index 02a94cafb6..c94e4f0145 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/StarGifts.swift @@ -720,7 +720,7 @@ public extension StarGift { extension StarGift { init?(apiStarGift: Api.StarGift) { switch apiStarGift { - case let .starGift(apiFlags, id, sticker, stars, availabilityRemains, availabilityTotal, availabilityResale, convertStars, firstSale, lastSale, upgradeStars, minResaleStars, title, releasedBy): + case let .starGift(apiFlags, id, sticker, stars, availabilityRemains, availabilityTotal, availabilityResale, convertStars, firstSale, lastSale, upgradeStars, minResaleStars, title, releasedBy, _, _): var flags = StarGift.Gift.Flags() if (apiFlags & (1 << 2)) != 0 { flags.insert(.isBirthdayGift) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/BUILD index 3620db8fe7..3d4e6b32e0 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/BUILD @@ -15,6 +15,9 @@ swift_library( "//submodules/Components/MultilineTextComponent", "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/Components/ComponentDisplayAdapters", + "//submodules/TooltipUI", + "//submodules/AccountContext", + "//submodules/UIKitRuntimeUtils", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift index 958a686997..7a1c065123 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift @@ -5,44 +5,62 @@ import ComponentFlow import MultilineTextComponent import TextLoadingEffect import ComponentDisplayAdapters +import TooltipUI +import AccountContext +import UIKitRuntimeUtils public final class PeerInfoRatingComponent: Component { + let context: AccountContext let backgroundColor: UIColor let foregroundColor: UIColor + let tooltipBackgroundColor: UIColor let isExpanded: Bool let compactLabel: String let fraction: CGFloat let label: String let nextLabel: String + let tooltipLabel: String let action: () -> Void public init( + context: AccountContext, backgroundColor: UIColor, foregroundColor: UIColor, + tooltipBackgroundColor: UIColor, isExpanded: Bool, compactLabel: String, fraction: CGFloat, label: String, nextLabel: String, + tooltipLabel: String, action: @escaping () -> Void ) { + self.context = context self.backgroundColor = backgroundColor self.foregroundColor = foregroundColor + self.tooltipBackgroundColor = tooltipBackgroundColor self.isExpanded = isExpanded self.compactLabel = compactLabel self.fraction = fraction self.label = label self.nextLabel = nextLabel + self.tooltipLabel = tooltipLabel self.action = action } public static func ==(lhs: PeerInfoRatingComponent, rhs: PeerInfoRatingComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } if lhs.backgroundColor != rhs.backgroundColor { return false } if lhs.foregroundColor != rhs.foregroundColor { return false } + if lhs.tooltipBackgroundColor != rhs.tooltipBackgroundColor { + return false + } if lhs.isExpanded != rhs.isExpanded { return false } @@ -58,6 +76,9 @@ public final class PeerInfoRatingComponent: Component { if lhs.nextLabel != rhs.nextLabel { return false } + if lhs.tooltipLabel != rhs.tooltipLabel { + return false + } return true } @@ -77,6 +98,8 @@ public final class PeerInfoRatingComponent: Component { private var component: PeerInfoRatingComponent? + private var tooltipController: TooltipScreen? + override public init(frame: CGRect) { self.backgroundView = UIImageView() @@ -261,6 +284,50 @@ public final class PeerInfoRatingComponent: Component { }) } + let tooltipController: TooltipScreen + if let current = self.tooltipController { + tooltipController = current + } else { + tooltipController = TooltipScreen( + context: component.context, + account: component.context.account, + sharedContext: component.context.sharedContext, + text: .attributedString(text: NSAttributedString(string: component.tooltipLabel, font: Font.semibold(11.0), textColor: .white)), + style: .customBlur(component.tooltipBackgroundColor, -4.0), + arrowStyle: .small, + location: .point(CGRect(origin: CGPoint(x: 100.0, y: 100.0), size: CGSize()), .bottom), + displayDuration: .infinite, + isShimmering: true, + cornerRadius: 10.0, + shouldDismissOnTouch: { _, _ in + return .ignore + } + ) + self.tooltipController = tooltipController + + tooltipController.containerLayoutUpdated(ContainerViewLayout( + size: CGSize(width: 200.0, height: 200.0), + metrics: LayoutMetrics(), + deviceMetrics: DeviceMetrics.iPhoneXSMax, + intrinsicInsets: UIEdgeInsets(), + safeInsets: UIEdgeInsets(), + additionalInsets: UIEdgeInsets(), + statusBarHeight: nil, + inputHeight: nil, + inputHeightIsInteractivellyChanging: false, + inVoiceOver: false + ), transition: .immediate) + + self.layer.addSublayer(tooltipController.view.layer) + tooltipController.viewWillAppear(false) + tooltipController.viewDidAppear(false) + tooltipController.setIgnoreAppearanceMethodInvocations(true) + tooltipController.view.isUserInteractionEnabled = false + } + + transition.setFrame(view: tooltipController.view, frame: CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -200.0 * 0.5 + foregroundFrame.width - 7.0, dy: -200.0 * 0.5)) + alphaTransition.setAlpha(view: tooltipController.view, alpha: component.isExpanded ? 1.0 : 0.0) + return size } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index 1b41e6642d..b1e01b97b6 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -677,7 +677,7 @@ public func keepPeerInfoScreenDataHot(context: AccountContext, peerId: PeerId, c if case .user = inputData { signals.append(Signal { _ in - let listContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + let listContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) let expiringListContext = PeerExpiringStoryListContext(account: context.account, peerId: peerId) return ActionDisposable { @@ -829,7 +829,7 @@ func peerInfoScreenSettingsData(context: AccountContext, peerId: EnginePeer.Id, ) |> distinctUntilChanged - let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) let hasStories: Signal = storyListContext.state |> map { state -> Bool? in if !state.hasCache { @@ -1188,7 +1188,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen secretChatKeyFingerprint = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.SecretChatKeyFingerprint(id: secretChatId)) } - let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) let hasStories: Signal = storyListContext.state |> map { state -> Bool? in if !state.hasCache { @@ -1201,7 +1201,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen let hasStoryArchive: Signal var storyArchiveListContext: StoryListContext? if isMyProfile { - let storyArchiveListContextValue = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: true) + let storyArchiveListContextValue = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: true, folderId: nil) storyArchiveListContext = storyArchiveListContextValue hasStoryArchive = storyArchiveListContextValue.state |> map { state -> Bool? in @@ -1535,7 +1535,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen let requestsContextPromise = Promise(nil) let requestsStatePromise = Promise(nil) - let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + let storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) let hasStories: Signal = storyListContext.state |> map { state -> Bool? in if !state.hasCache { @@ -1857,7 +1857,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen let storyListContext: StoryListContext? let hasStories: Signal if peerId.namespace == Namespaces.Peer.CloudChannel { - storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false) + storyListContext = PeerStoryListContext(account: context.account, peerId: peerId, isArchived: false, folderId: nil) hasStories = storyListContext!.state |> map { state -> Bool? in if !state.hasCache { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index d5050ab07b..bbaacddc33 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -1926,22 +1926,47 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.subtitleRating = subtitleRating } let fraction: CGFloat + let tooltipLabel: String if let nextLevelStars = starRating.nextLevelStars { fraction = CGFloat(starRating.currentLevelStars) / CGFloat(nextLevelStars) + tooltipLabel = "\(starRating.currentLevelStars) / \(nextLevelStars)" } else { fraction = 1.0 + tooltipLabel = "" } + + let tooltipBackgroundColor: UIColor + let ratingBackgroundColor: UIColor + let ratingForegroundColor: UIColor + + if peer?.profileColor != nil { + ratingBackgroundColor = UIColor(white: 1.0, alpha: 0.1) + ratingForegroundColor = UIColor(white: 1.0, alpha: 1.0) + if !self.isAvatarExpanded { + tooltipBackgroundColor = contentButtonBackgroundColor + } else { + tooltipBackgroundColor = UIColor(rgb: 0x000000, alpha: 0.65) + } + } else { + ratingBackgroundColor = presentationData.theme.list.freeTextColor.withMultipliedAlpha(0.1) + ratingForegroundColor = presentationData.theme.list.freeTextColor.withMultipliedAlpha(1.0) + tooltipBackgroundColor = UIColor(rgb: 0x000000, alpha: 0.65) + } + //TODO:localize subtitleRatingSize = subtitleRating.update( transition: subtitleRatingTransition, component: AnyComponent(PeerInfoRatingComponent( - backgroundColor: UIColor(white: 1.0, alpha: 0.1), - foregroundColor: UIColor(white: 1.0, alpha: 1.0), + context: self.context, + backgroundColor: ratingBackgroundColor, + foregroundColor: ratingForegroundColor, + tooltipBackgroundColor: tooltipBackgroundColor, isExpanded: self.subtitleRatingIsExpanded, compactLabel: "\(starRating.level)", fraction: fraction, label: "Level \(starRating.level)", nextLabel: starRating.nextLevelStars != nil ? "\(starRating.level + 1)" : "", + tooltipLabel: tooltipLabel, action: { [weak self] in guard let self else { return @@ -1994,7 +2019,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { let rawSubtitleFrame = CGRect(origin: CGPoint(x: subtitleCenter.x - subtitleFrame.size.width / 2.0, y: subtitleCenter.y - subtitleFrame.size.height / 2.0), size: subtitleFrame.size) self.subtitleNodeRawContainer.frame = rawSubtitleFrame transition.updateFrameAdditiveToCenter(node: self.subtitleNodeContainer, frame: CGRect(origin: rawSubtitleFrame.center, size: CGSize())) - transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(x: 0.0, y: subtitleOffset), size: CGSize())) + transition.updatePosition(node: self.subtitleNode, position: CGPoint(x: 0.0, y: subtitleOffset)) transition.updateFrame(node: self.panelSubtitleNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelSubtitleOffset - 1.0), size: CGSize())) transition.updateFrame(node: self.usernameNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateSublayerTransformScale(node: self.titleNodeContainer, scale: titleScale) @@ -2006,6 +2031,17 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateFrameAdditive(view: subtitleBadgeView, frame: subtitleBadgeFrame) transition.updateAlpha(layer: subtitleBadgeView.layer, alpha: (1.0 - transitionFraction)) } + + if let subtitleRatingView = self.subtitleRating?.view, let subtitleRatingSize { + let subtitleBadgeFrame: CGRect + if self.subtitleRatingIsExpanded { + subtitleBadgeFrame = CGRect(origin: CGPoint(x: -subtitleRatingSize.width * 0.5, y: floor((-subtitleRatingSize.height) * 0.5)), size: subtitleRatingSize) + } else { + subtitleBadgeFrame = CGRect(origin: CGPoint(x: (-subtitleSize.width) * 0.5 - 4.0 - subtitleRatingSize.width, y: floor((-subtitleRatingSize.height) * 0.5)), size: subtitleRatingSize) + } + transition.updateFrameAdditive(view: subtitleRatingView, frame: subtitleBadgeFrame) + transition.updateAlpha(layer: subtitleRatingView.layer, alpha: (1.0 - transitionFraction)) + } } else { let titleScale: CGFloat let subtitleScale: CGFloat @@ -2048,7 +2084,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { usernameCenter.x = rawTitleFrame.center.x + (usernameCenter.x - rawTitleFrame.center.x) * subtitleScale transition.updateFrameAdditiveToCenter(node: self.usernameNodeContainer, frame: CGRect(origin: usernameCenter, size: CGSize()).offsetBy(dx: 0.0, dy: titleOffset)) } - transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(x: 0.0, y: subtitleOffset), size: CGSize())) + transition.updatePosition(node: self.subtitleNode, position: CGPoint(x: 0.0, y: subtitleOffset)) transition.updateFrame(node: self.panelSubtitleNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelSubtitleOffset - 1.0), size: CGSize())) transition.updateFrame(node: self.usernameNode, frame: CGRect(origin: CGPoint(), size: CGSize())) transition.updateSublayerTransformScaleAdditive(node: self.titleNodeContainer, scale: titleScale) @@ -2072,7 +2108,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { transition.updateAlpha(layer: subtitleRatingView.layer, alpha: (1.0 - transitionFraction) * subtitleRatingFraction) } - transition.updateAlpha(node: self.subtitleNode, alpha: self.subtitleRatingIsExpanded ? 0.0 : 1.0) + let subtitleAlpha: CGFloat = subtitleRatingFraction * (self.subtitleRatingIsExpanded ? 0.0 : 1.0) + (1.0 - subtitleRatingFraction) * 1.0 + let subtitleInnerScale: CGFloat = subtitleRatingFraction * (self.subtitleRatingIsExpanded ? 0.001 : 1.0) + (1.0 - subtitleRatingFraction) * 1.0 + transition.updateAlpha(node: self.subtitleNode, alpha: subtitleAlpha) + transition.updateTransformScale(node: self.subtitleNode, scale: subtitleInnerScale) } } @@ -2600,6 +2639,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { self.isAvatarExpanded = isAvatarExpanded if isAvatarExpanded { self.avatarListNode.listContainerNode.selectFirstItem() + self.subtitleRatingIsExpanded = false } if case .animated = transition, !isAvatarExpanded { self.avatarListNode.animateAvatarCollapse(transition: transition) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 2e8031d838..7413982b8e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -11496,26 +11496,150 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro private func displayMediaGalleryContextMenu(source: ContextReferenceContentNode, gesture: ContextGesture?) { let peerId = self.peerId - if let currentPaneKey = self.paneContainerNode.currentPaneKey, case .botPreview = currentPaneKey { + var isBotPreviewOrStories = false + if let currentPaneKey = self.paneContainerNode.currentPaneKey { + if case .botPreview = currentPaneKey { + isBotPreviewOrStories = true + } else if case .stories = currentPaneKey { + isBotPreviewOrStories = true + } + } + + if isBotPreviewOrStories { guard let controller = self.controller else { return } guard let pane = self.paneContainerNode.currentPane?.node as? PeerInfoStoryPaneNode else { return } - guard let data = self.data, let user = data.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) else { - return - } - var items: [ContextMenuItem] = [] - - let strings = self.presentationData.strings - - var ignoreNextActions = false - - if pane.canAddMoreBotPreviews() { - items.append(.action(ContextMenuActionItem(text: strings.BotPreviews_MenuAddPreview, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) + if case .botPreview = pane.scope { + guard let data = self.data, let user = data.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) else { + return + } + + var items: [ContextMenuItem] = [] + + let strings = self.presentationData.strings + + var ignoreNextActions = false + + if pane.canAddMoreBotPreviews() { + items.append(.action(ContextMenuActionItem(text: strings.BotPreviews_MenuAddPreview, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + if ignoreNextActions { + return + } + ignoreNextActions = true + a(.default) + + if let self { + self.headerNode.navigationButtonContainer.performAction?(.postStory, nil, nil) + } + }))) + } + + if pane.canReorder() { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuReorder, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) + }, action: { [weak pane] _, a in + if ignoreNextActions { + return + } + ignoreNextActions = true + a(.default) + + if let pane { + pane.beginReordering() + } + }))) + } + + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuSelect, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + if ignoreNextActions { + return + } + ignoreNextActions = true + a(.default) + + if let self { + self.toggleStorySelection(ids: [], isSelected: true) + } + }))) + + if let language = pane.currentBotPreviewLanguage { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuDeleteLanguage(language.name).string, 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 { + return .ignore + } + + let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil) + guard let localResult = strongSelf.hitTest(localPoint, with: nil) else { + return .dismiss(consume: true, result: nil) + } + + var testView: UIView? = localResult + while true { + if let testViewValue = testView { + if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton { + node.isUserInteractionEnabled = false + DispatchQueue.main.async { + node.isUserInteractionEnabled = true + } + return .dismiss(consume: false, result: nil) + } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode { + node.brieflyDisableTouchActions() + return .dismiss(consume: false, result: nil) + } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoStoryPaneNode { + node.brieflyDisableTouchActions() + return .dismiss(consume: false, result: nil) + } else { + testView = testViewValue.superview + } + } else { + break + } + } + + return .dismiss(consume: true, result: nil) + } + self.mediaGalleryContextMenu = contextController + controller.presentInGlobalOverlay(contextController) + } else if case .peer = pane.scope { + guard let data = self.data, let user = data.peer as? TelegramUser else { + return + } + let _ = user + + var items: [ContextMenuItem] = [] + + let strings = self.presentationData.strings + let _ = strings + + var ignoreNextActions = false + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Add Stories", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/AddStoryIcon"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in if ignoreNextActions { return @@ -11527,92 +11651,96 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self.headerNode.navigationButtonContainer.performAction?(.postStory, nil, nil) } }))) - } - - if pane.canReorder() { - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuReorder, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) - }, action: { [weak pane] _, a in - if ignoreNextActions { - return - } - ignoreNextActions = true - a(.default) - - if let pane { - pane.beginReordering() - } - }))) - } - - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuSelect, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) - }, action: { [weak self] _, a in - if ignoreNextActions { - return - } - ignoreNextActions = true - a(.default) - if let self { - self.toggleStorySelection(ids: [], isSelected: true) - } - }))) - - if let language = pane.currentBotPreviewLanguage { - items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuDeleteLanguage(language.name).string, 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 { - return .ignore + if pane.canReorder() { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuReorder, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) + }, action: { [weak pane] _, a in + if ignoreNextActions { + return + } + ignoreNextActions = true + a(.default) + + if let pane { + pane.beginReordering() + } + }))) } - let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil) - guard let localResult = strongSelf.hitTest(localPoint, with: nil) else { + if let folder = pane.currentStoryFolder { + let _ = folder + + items.append(.action(ContextMenuActionItem(text: "Delete Album", 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.presentDeleteCurrentStoryFolder() + } + }))) + } + + if let language = pane.currentBotPreviewLanguage { + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.BotPreviews_MenuDeleteLanguage(language.name).string, 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 { + return .ignore + } + + let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil) + guard let localResult = strongSelf.hitTest(localPoint, with: nil) else { + return .dismiss(consume: true, result: nil) + } + + var testView: UIView? = localResult + while true { + if let testViewValue = testView { + if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton { + node.isUserInteractionEnabled = false + DispatchQueue.main.async { + node.isUserInteractionEnabled = true + } + return .dismiss(consume: false, result: nil) + } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode { + node.brieflyDisableTouchActions() + return .dismiss(consume: false, result: nil) + } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoStoryPaneNode { + node.brieflyDisableTouchActions() + return .dismiss(consume: false, result: nil) + } else { + testView = testViewValue.superview + } + } else { + break + } + } + return .dismiss(consume: true, result: nil) } - - var testView: UIView? = localResult - while true { - if let testViewValue = testView { - if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton { - node.isUserInteractionEnabled = false - DispatchQueue.main.async { - node.isUserInteractionEnabled = true - } - return .dismiss(consume: false, result: nil) - } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode { - node.brieflyDisableTouchActions() - return .dismiss(consume: false, result: nil) - } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoStoryPaneNode { - node.brieflyDisableTouchActions() - return .dismiss(consume: false, result: nil) - } else { - testView = testViewValue.superview - } - } else { - break - } - } - - return .dismiss(consume: true, result: nil) + self.mediaGalleryContextMenu = contextController + controller.presentInGlobalOverlay(contextController) } - self.mediaGalleryContextMenu = contextController - controller.presentInGlobalOverlay(contextController) } else { let _ = (self.context.engine.data.get(EngineDataMap([ TelegramEngine.EngineData.Item.Messages.MessageCount(peerId: peerId, threadId: self.chatLocation.threadId, tag: .photo), @@ -12482,6 +12610,10 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro if let data = self.data, data.hasBotPreviewItems, let user = data.peer as? TelegramUser, let botInfo = user.botInfo, botInfo.flags.contains(.canEdit) { rightNavigationButtons.append(PeerInfoHeaderNavigationButtonSpec(key: .more, isForExpandedView: true)) } + case .stories: + if let data = self.data, data.peer?.id == self.context.account.peerId { + rightNavigationButtons.append(PeerInfoHeaderNavigationButtonSpec(key: .more, isForExpandedView: true)) + } case .gifts: //if let data = self.data, let channel = data.peer as? TelegramChannel, case .broadcast = channel.info { rightNavigationButtons.append(PeerInfoHeaderNavigationButtonSpec(key: .sort, isForExpandedView: true)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift index 64d4b4c7ce..efce1ee57d 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift @@ -24,15 +24,18 @@ final class PeerInfoStoryGridScreenComponent: Component { let context: AccountContext let peerId: EnginePeer.Id let scope: PeerInfoStoryGridScreen.Scope + let selectionModeCompletion: (([EngineStoryItem]) -> Void)? init( context: AccountContext, peerId: EnginePeer.Id, - scope: PeerInfoStoryGridScreen.Scope + scope: PeerInfoStoryGridScreen.Scope, + selectionModeCompletion: (([EngineStoryItem]) -> Void)? ) { self.context = context self.peerId = peerId self.scope = scope + self.selectionModeCompletion = selectionModeCompletion } static func ==(lhs: PeerInfoStoryGridScreenComponent, rhs: PeerInfoStoryGridScreenComponent) -> Bool { @@ -342,11 +345,24 @@ final class PeerInfoStoryGridScreenComponent: Component { } let buttonText: String - switch component.scope { - case .saved: - buttonText = self.selectedCount > 0 ? environment.strings.ChatList_Context_Archive : environment.strings.StoryList_SavedAddAction - case .archive: - buttonText = environment.strings.StoryList_SaveToProfile + var buttonIsEnabled = true + if component.selectionModeCompletion != nil { + //TODO:localize + if self.selectedCount == 0 { + buttonText = "Add Stories" + buttonIsEnabled = false + } else if self.selectedCount == 1 { + buttonText = "Add 1 Story" + } else { + buttonText = "Add \(self.selectedCount) Stories" + } + } else { + switch component.scope { + case .saved: + buttonText = self.selectedCount > 0 ? environment.strings.ChatList_Context_Archive : environment.strings.StoryList_SavedAddAction + case .archive: + buttonText = environment.strings.StoryList_SaveToProfile + } } let selectionPanelSize = selectionPanel.update( @@ -355,7 +371,7 @@ final class PeerInfoStoryGridScreenComponent: Component { theme: environment.theme, title: buttonText, label: nil, - isEnabled: true, + isEnabled: buttonIsEnabled, insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: environment.safeInsets.bottom, right: sideInset), action: { [weak self] in guard let self, let component = self.component, let environment = self.environment else { @@ -365,6 +381,12 @@ final class PeerInfoStoryGridScreenComponent: Component { return } + if let selectionModeCompletion = component.selectionModeCompletion { + selectionModeCompletion(Array(paneNode.selectedItems.values)) + environment.controller()?.dismiss() + return + } + let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let peer else { @@ -522,6 +544,12 @@ final class PeerInfoStoryGridScreenComponent: Component { } (self.environment?.controller() as? PeerInfoStoryGridScreen)?.updateTitle() }) + + if component.selectionModeCompletion != nil { + paneNode.shouldOpenItemsWhileInSelectionMode = false + paneNode.setIsSelectionModeActive(true) + } + applyState = true } @@ -562,6 +590,7 @@ public class PeerInfoStoryGridScreen: ViewControllerComponentContainer { private let context: AccountContext private let scope: Scope + private let selectionModeCompletion: (([EngineStoryItem]) -> Void)? private var isDismissed: Bool = false private var titleView: ChatTitleView? @@ -573,15 +602,18 @@ public class PeerInfoStoryGridScreen: ViewControllerComponentContainer { public init( context: AccountContext, peerId: EnginePeer.Id, - scope: Scope + scope: Scope, + selectionModeCompletion: (([EngineStoryItem]) -> Void)? = nil ) { self.context = context self.scope = scope + self.selectionModeCompletion = selectionModeCompletion super.init(context: context, component: PeerInfoStoryGridScreenComponent( context: context, peerId: peerId, - scope: scope + scope: scope, + selectionModeCompletion: selectionModeCompletion ), navigationBarAppearance: .default, theme: .default) let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) @@ -636,49 +668,54 @@ public class PeerInfoStoryGridScreen: ViewControllerComponentContainer { func updateTitle() { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - switch self.scope { - case .saved: - guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View, let paneNode = componentView.paneNode else { - return - } - let title: String? - if componentView.selectedCount != 0 { - title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount)) - } else if let paneStatusText = componentView.paneStatusText, !paneStatusText.isEmpty { - title = paneStatusText - } else { - title = nil - } - self.titleView?.titleContent = .custom(presentationData.strings.StoryList_TitleSaved, title, false) - - if paneNode.isSelectionModeActive { - self.navigationItem.setRightBarButton(self.doneBarButtonItem, animated: false) - } else { - self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false) - } - case .archive: - guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View else { - return - } - let title: String - if componentView.selectedCount != 0 { - title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount)) - } else { - title = presentationData.strings.StoryList_TitleArchive - } - self.titleView?.titleContent = .custom(title, nil, false) - - var hasMenu = false - if componentView.selectedCount != 0 { - hasMenu = true - } else if let paneNode = componentView.paneNode, !paneNode.isEmpty { - hasMenu = true - } - - if hasMenu { - self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false) - } else { - self.navigationItem.setRightBarButton(nil, animated: false) + if self.selectionModeCompletion != nil { + //TODO:localize + self.titleView?.titleContent = .custom("Add Stories", nil, false) + } else { + switch self.scope { + case .saved: + guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View, let paneNode = componentView.paneNode else { + return + } + let title: String? + if componentView.selectedCount != 0 { + title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount)) + } else if let paneStatusText = componentView.paneStatusText, !paneStatusText.isEmpty { + title = paneStatusText + } else { + title = nil + } + self.titleView?.titleContent = .custom(presentationData.strings.StoryList_TitleSaved, title, false) + + if paneNode.isSelectionModeActive { + self.navigationItem.setRightBarButton(self.doneBarButtonItem, animated: false) + } else { + self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false) + } + case .archive: + guard let componentView = self.node.hostView.componentView as? PeerInfoStoryGridScreenComponent.View else { + return + } + let title: String + if componentView.selectedCount != 0 { + title = presentationData.strings.StoryList_SubtitleSelected(Int32(componentView.selectedCount)) + } else { + title = presentationData.strings.StoryList_TitleArchive + } + self.titleView?.titleContent = .custom(title, nil, false) + + var hasMenu = false + if componentView.selectedCount != 0 { + hasMenu = true + } else if let paneNode = componentView.paneNode, !paneNode.isEmpty { + hasMenu = true + } + + if hasMenu { + self.navigationItem.setRightBarButton(self.moreBarButtonItem, animated: false) + } else { + self.navigationItem.setRightBarButton(nil, animated: false) + } } } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD index 2b06f96f7d..e106558332 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD @@ -54,6 +54,8 @@ swift_library( "//submodules/Components/BalancedTextComponent", "//submodules/TelegramUI/Components/CheckComponent", "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/BottomButtonPanelComponent", + "//submodules/PromptUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index e27a8c2ab5..fa413a2773 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -44,6 +44,8 @@ import MultilineTextComponent import LocationUI import TabSelectorComponent import LanguageSelectionScreen +import PromptUI +import BottomButtonPanelComponent private let mediaBadgeBackgroundColor = UIColor(white: 0.0, alpha: 0.6) private let mediaBadgeTextColor = UIColor.white @@ -1543,7 +1545,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } private let context: AccountContext - private let scope: Scope + public let scope: Scope private let isProfileEmbedded: Bool private let canManageStories: Bool @@ -1568,7 +1570,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private var mapInfoNode: LocationInfoListItemNode? private var searchHeader: ComponentView? - private var botPreviewLanguageTab: ComponentView? + private var folderTab: ComponentView? private var botPreviewFooter: ComponentView? private var barBackgroundLayer: SimpleLayer? @@ -1583,6 +1585,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private var didUpdateItemsOnce: Bool = false private var selectionPanel: ComponentView? + private var actionPanel: ComponentView? private var isDeceleratingAfterTracking = false @@ -1627,6 +1630,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public var isEmptyUpdated: (Bool) -> Void = { _ in } + public var shouldOpenItemsWhileInSelectionMode: Bool = true public private(set) var isSelectionModeActive: Bool private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData)? @@ -1647,6 +1651,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public var tabBarOffset: CGFloat { if case .botPreview = self.scope { return 0.0 + } else if case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded { + return 0.0 } else { return self.itemGrid.coveringInsetOffset } @@ -1660,6 +1666,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private var currentBotPreviewLanguages: [StoryListContext.State.Language] = [] private var removedBotPreviewLanguages = Set() + private var currentStoryFolders: [StoryListContext.State.Folder] = [] + private var removedStoryFolders = Set() + private var numberOfItemsToRequest: Int = 50 private var isRequestingView: Bool = false private var isFirstHistoryView: Bool = true @@ -1674,7 +1683,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private let maxBotPreviewCount: Int private let defaultListSource: StoryListContext - private var cachedListSources: [String: StoryListContext] = [:] + private var cachedListSources: [AnyHashable: StoryListContext] = [:] public var currentBotPreviewLanguage: (id: String, name: String)? { guard let listSource = self.listSource as? BotPreviewStoryListContext else { @@ -1688,6 +1697,19 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } return (language.id, language.name) } + + public var currentStoryFolder: (id: Int64, title: String)? { + guard let listSource = self.listSource as? PeerStoryListContext else { + return nil + } + guard let id = listSource.folderId else { + return nil + } + guard let folder = self.currentStoryFolders.first(where: { $0.id == id }) else { + return nil + } + return (folder.id, folder.title) + } public var openCurrentDate: (() -> Void)? public var paneDidScroll: (() -> Void)? @@ -1749,7 +1771,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } else { switch self.scope { case let .peer(id, _, isArchived): - self.listSource = PeerStoryListContext(account: context.account, peerId: id, isArchived: isArchived) + self.listSource = PeerStoryListContext(account: context.account, peerId: id, isArchived: isArchived, folderId: nil) case let .search(peerId, query): self.listSource = SearchStoryListContext(account: context.account, source: .hashtag(peerId, query)) case let .location(coordinates, venue): @@ -1798,7 +1820,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr return } - if self.isProfileEmbedded { + if self.isProfileEmbedded || !self.shouldOpenItemsWhileInSelectionMode { if let selectedIds = self.itemInteraction.selectedIds { self.itemInteraction.toggleSelection(item.story.id, !selectedIds.contains(item.story.id)) return @@ -2275,7 +2297,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: false) if case let .peer(id, _, isArchived) = self.scope, id == context.account.peerId, !isArchived { - self.preloadArchiveListContext = PeerStoryListContext(account: context.account, peerId: context.account.peerId, isArchived: true) + self.preloadArchiveListContext = PeerStoryListContext(account: context.account, peerId: context.account.peerId, isArchived: true, folderId: nil) } if case let .location(_, venue) = scope, let mapNode = self.mapNode { @@ -2447,6 +2469,122 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr var items: [ContextMenuItem] = [] if canManage, case let .peer(peerId, _, isArchived) = self.scope { + if peerId == self.context.account.peerId && self.isProfileEmbedded { + if let folder = self.currentStoryFolder { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Remove from Album", textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in + guard let self else { + f(.default) + return + } + + if let listSource = self.listSource as? PeerStoryListContext { + listSource.removeFromFolder(id: folder.id, itemIds: [item.id]) + } + + f(.dismissWithoutContent) + }))) + } else { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Add to Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + guard let self, let c else { + f(.default) + return + } + + Task { @MainActor [weak self, weak c] in + guard let self, let c else { + return + } + + let (peerReference, folderPreviews) = await PeerStoryListContext.folderPreviews(peerId: peerId, account: self.context.account).get() + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { c ,f in + c?.popItems() + }))) + items.append(.separator) + + items.append(.action(ContextMenuActionItem(text: "New Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { [weak self] c, f in + guard let self else { + f(.default) + return + } + + c?.dismiss(completion: { [weak self] in + guard let self else { + return + } + self.presentAddStoryFolder(addItems: [item]) + }) + }))) + + for folderPreview in folderPreviews { + var iconSource: ContextMenuActionItemIconSource? + if let story = folderPreview.item { + var imageSignal: Signal? + + var selectedMedia: Media? + if let image = story.media._asMedia() as? TelegramMediaImage { + selectedMedia = image + } else if let file = story.media._asMedia() as? TelegramMediaFile { + selectedMedia = file + } + + if let selectedMedia { + if let result = self.directMediaImageCache.getImage(peer: peerReference, story: story, media: selectedMedia, width: 24, aspectRatio: 1.0, possibleWidths: [24], includeBlurred: false, synchronous: true) { + if let loadSignal = result.loadSignal { + imageSignal = .single(result.image) |> then(loadSignal) + } else { + imageSignal = .single(result.image) + } + } + } + + if let imageSignal { + iconSource = ContextMenuActionItemIconSource( + size: CGSize(width: 24.0, height: 24.0), + cornerRadius: 5.0, + signal: imageSignal + ) + } + } + + var icon: (PresentationTheme) -> UIImage? = { _ in nil } + if iconSource == nil { + icon = { theme in + return generateImage(CGSize(width: 24.0, height: 24.0), opaque: false, scale: nil, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(theme.contextMenu.primaryColor.withMultipliedAlpha(0.1).cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 5.0).cgPath) + context.fillPath() + }) + } + } + + items.append(.action(ContextMenuActionItem(text: folderPreview.folder.title, icon: icon, iconSource: iconSource, iconPosition: .left, action: { [weak self] c, f in + guard let self else { + f(.default) + return + } + + c?.dismiss(completion: {}) + + if let listSource = self.listSource as? PeerStoryListContext { + listSource.addToFolder(id: folderPreview.folder.id, items: [item]) + } + }))) + } + + c.pushItems(items: .single(ContextController.Items(content: .list(items)))) + } + }))) + } + items.append(.separator) + } + items.append(.action(ContextMenuActionItem(text: !isArchived ? self.presentationData.strings.StoryList_ItemAction_Archive : self.presentationData.strings.StoryList_ItemAction_Unarchive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isArchived ? "Chat/Context Menu/Archive" : "Chat/Context Menu/Unarchive"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in guard let self else { f(.default) @@ -2784,6 +2922,13 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } botPreviewLanguages.sort(by: { $0.name < $1.name }) + var storyFolders = self.currentStoryFolders + for folder in state.availableFolders { + if !storyFolders.contains(where: { $0.id == folder.id }) && !self.removedStoryFolders.contains(folder.id) { + storyFolders.append(folder) + } + } + var hadLocalItems = false if let currentListState = self.currentListState { for item in currentListState.items { @@ -2811,8 +2956,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.updateItemsFromState(state: state, firstTime: firstTime, reloadAtTop: reloadAtTop, synchronous: synchronous, animated: false) - if self.currentBotPreviewLanguages != botPreviewLanguages || reloadAtTop { + if self.currentBotPreviewLanguages != botPreviewLanguages || self.currentStoryFolders != storyFolders || reloadAtTop { self.currentBotPreviewLanguages = botPreviewLanguages + self.currentStoryFolders = storyFolders 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) } @@ -2870,7 +3016,11 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr isReorderable = !item.storyItem.isPending case let .peer(id, _, _): if id == self.context.account.peerId { - isReorderable = state.pinnedIds.contains(item.storyItem.id) + if self.currentStoryFolder != nil { + isReorderable = true + } else { + isReorderable = state.pinnedIds.contains(item.storyItem.id) + } } case let .search(peerId, _): if peerId != nil { @@ -2963,11 +3113,14 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if case .botPreview = self.scope { } else if case let .peer(id, _, _) = self.scope { if id == self.context.account.peerId { - let maxPinnedIndex = items.items.lastIndex(where: { ($0 as? VisualMediaItem)?.isPinned == true }) - if let maxPinnedIndex { - toIndex = min(toIndex, maxPinnedIndex) + if self.currentStoryFolder != nil { } else { - return + let maxPinnedIndex = items.items.lastIndex(where: { ($0 as? VisualMediaItem)?.isPinned == true }) + if let maxPinnedIndex { + toIndex = min(toIndex, maxPinnedIndex) + } else { + return + } } } } else { @@ -3379,8 +3532,10 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr 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) + if case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded { + self.updateFolderTab(size: currentParams.size, topInset: currentParams.topInset, transition: transition) + } else if case .botPreview = self.scope, self.canManageStories { + self.updateFolderTab(size: currentParams.size, topInset: currentParams.topInset, transition: transition) self.updateBotPreviewFooter(size: currentParams.size, bottomInset: 0.0, transition: transition) } } @@ -3536,40 +3691,69 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } - private func updateBotPreviewLanguageTab(size: CGSize, topInset: CGFloat, transition: ContainedViewLayoutTransition) { - guard case .botPreview = self.scope, self.canManageStories else { + private func updateFolderTab(size: CGSize, topInset: CGFloat, transition: ContainedViewLayoutTransition) { + var displayFolderTab = false + if case .botPreview = self.scope, self.canManageStories { + displayFolderTab = true + } else if case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded { + displayFolderTab = true + } + + if !displayFolderTab { return } - let botPreviewLanguageTab: ComponentView - if let current = self.botPreviewLanguageTab { - botPreviewLanguageTab = current + let folderTab: ComponentView + if let current = self.folderTab { + folderTab = current } else { - botPreviewLanguageTab = ComponentView() - self.botPreviewLanguageTab = botPreviewLanguageTab + folderTab = ComponentView() + self.folderTab = folderTab } - var languageItems: [TabSelectorComponent.Item] = [] - languageItems.append(TabSelectorComponent.Item( + var folderItems: [TabSelectorComponent.Item] = [] + let mainTitle: String + let addTitle: String + if case .botPreview = self.scope { + mainTitle = self.presentationData.strings.BotPreviews_LanguageTab_Main + addTitle = self.presentationData.strings.BotPreviews_LanguageTab_Add + } else { + //TODO:localize + mainTitle = "All Stories" + addTitle = "+ Add Album" + } + folderItems.append(TabSelectorComponent.Item( id: AnyHashable("_main"), - title: self.presentationData.strings.BotPreviews_LanguageTab_Main + title: mainTitle )) - for language in self.currentBotPreviewLanguages { - languageItems.append(TabSelectorComponent.Item( - id: AnyHashable(language.id), - title: language.name - )) + + if case .botPreview = self.scope { + for language in self.currentBotPreviewLanguages { + folderItems.append(TabSelectorComponent.Item( + id: AnyHashable(language.id), + title: language.name + )) + } + } else { + for folder in self.currentStoryFolders { + folderItems.append(TabSelectorComponent.Item( + id: AnyHashable(folder.id), + title: folder.title + )) + } } - languageItems.append(TabSelectorComponent.Item( + folderItems.append(TabSelectorComponent.Item( id: AnyHashable("_add"), - title: self.presentationData.strings.BotPreviews_LanguageTab_Add + title: addTitle )) - var selectedLanguageId = "_main" + var selectedId = AnyHashable("_main") if let listSource = self.listSource as? BotPreviewStoryListContext, let language = listSource.language { - selectedLanguageId = language + selectedId = AnyHashable(language) + } else if let listSource = self.listSource as? PeerStoryListContext, let folderId = listSource.folderId { + selectedId = AnyHashable(folderId) } - let botPreviewLanguageTabSize = botPreviewLanguageTab.update( + let folderTabSize = folderTab.update( transition: ComponentTransition(transition), component: AnyComponent(TabSelectorComponent( colors: TabSelectorComponent.Colors( @@ -3581,39 +3765,53 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr spacing: 9.0, verticalInset: 11.0 ), - items: languageItems, - selectedId: AnyHashable(selectedLanguageId), + items: folderItems, + selectedId: selectedId, setSelectedId: { [weak self] id in - guard let self, let id = id.base as? String else { + guard let self 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) + if let id = id.base as? String { + if id == "_add" { + if case .botPreview = self.scope { + self.presentAddBotPreviewLanguage() + } else { + self.presentAddStoryFolder() + } + } else if id == "_main" { + if case .botPreview = self.scope { + self.setBotPreviewLanguage(id: nil, assumeEmpty: false) + } else { + self.setStoryFolder(id: nil, assumeEmpty: false) + } + } else if let language = self.currentBotPreviewLanguages.first(where: { $0.id == id }) { + self.setBotPreviewLanguage(id: language.id, assumeEmpty: false) + } + } else if let id = id.base as? Int64 { + if let folder = self.currentStoryFolders.first(where: { $0.id == id }) { + self.setStoryFolder(id: folder.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) + var folderTabFrame = CGRect(origin: CGPoint(x: floor((size.width - folderTabSize.width) * 0.5), y: topInset - 11.0), size: folderTabSize) let effectiveScrollingOffset: CGFloat effectiveScrollingOffset = self.itemGrid.scrollingOffset - botPreviewLanguageTabFrame.origin.y -= effectiveScrollingOffset + folderTabFrame.origin.y -= effectiveScrollingOffset let isSelectingOrReordering = self.isReordering || self.itemInteraction.selectedIds != nil - if let botPreviewLanguageTabView = botPreviewLanguageTab.view { - if botPreviewLanguageTabView.superview == nil { - self.view.addSubview(botPreviewLanguageTabView) + if let folderTabView = folderTab.view { + if folderTabView.superview == nil { + self.view.addSubview(folderTabView) } - transition.updateFrame(view: botPreviewLanguageTabView, frame: botPreviewLanguageTabFrame) - transition.updateAlpha(layer: botPreviewLanguageTabView.layer, alpha: isSelectingOrReordering ? 0.5 : 1.0) - botPreviewLanguageTabView.isUserInteractionEnabled = !isSelectingOrReordering + transition.updateFrame(view: folderTabView, frame: folderTabFrame) + transition.updateAlpha(layer: folderTabView.layer, alpha: isSelectingOrReordering ? 0.5 : 1.0) + folderTabView.isUserInteractionEnabled = !isSelectingOrReordering } } @@ -3742,15 +3940,24 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr var listBottomInset = bottomInset var bottomInset = bottomInset + var displayFolderTab = false if case .botPreview = self.scope, self.canManageStories { - updateBotPreviewLanguageTab(size: size, topInset: topInset, transition: transition) + displayFolderTab = true + } else if case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded { + displayFolderTab = true + } + + if displayFolderTab { + updateFolderTab(size: size, topInset: topInset, transition: transition) gridTopInset += 50.0 - updateBotPreviewFooter(size: size, bottomInset: 0.0, transition: transition) - if let botPreviewFooterView = self.botPreviewFooter?.view { + if case .botPreview = self.scope { + updateBotPreviewFooter(size: size, bottomInset: 0.0, transition: transition) + if let botPreviewFooterView = self.botPreviewFooter?.view { listBottomInset += 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 @@ -3930,13 +4137,200 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } else if let selectionPanel = self.selectionPanel { self.selectionPanel = nil if let selectionPanelView = selectionPanel.view { - transition.updateFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height), size: selectionPanelView.bounds.size)) + transition.updateFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height), size: selectionPanelView.bounds.size), completion: { [weak selectionPanelView] _ in + selectionPanelView?.removeFromSuperview() + }) + } + } + + if self.selectionPanel == nil, self.isProfileEmbedded, self.canManageStories, case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded, self.currentStoryFolder != nil, let items = self.items, !items.items.isEmpty { + let actionPanel: ComponentView + var actionPanelTransition = ComponentTransition(transition) + if let current = self.actionPanel { + actionPanel = current + } else { + actionPanelTransition = actionPanelTransition.withAnimation(.none) + actionPanel = ComponentView() + self.actionPanel = actionPanel + } + + //TODO:localize + let actionPanelSize = actionPanel.update( + transition: actionPanelTransition, + component: AnyComponent(BottomButtonPanelComponent( + theme: presentationData.theme, + title: "Add Stories", + label: nil, + isEnabled: true, + insets: UIEdgeInsets(top: 0.0, left: sideInset + 12.0, bottom: bottomInset, right: sideInset + 12.0), + action: { [weak self] in + guard let self else { + return + } + self.presentAddStoriesToFolder() + } + )), + environment: {}, + containerSize: size + ) + let actionPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - actionPanelSize.height), size: actionPanelSize) + if let actionPanelView = actionPanel.view { + if actionPanelView.superview == nil { + self.view.addSubview(actionPanelView) + transition.animatePositionAdditive(layer: actionPanelView.layer, offset: CGPoint(x: 0.0, y: actionPanelFrame.height)) + } + actionPanelTransition.setFrame(view: actionPanelView, frame: actionPanelFrame) + } + bottomInset = actionPanelSize.height + listBottomInset += actionPanelSize.height + } else if let actionPanel = self.actionPanel { + self.actionPanel = nil + if let actionPanelView = actionPanel.view { + transition.updateFrame(view: actionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height), size: actionPanelView.bounds.size), completion: { [weak actionPanelView] _ in + actionPanelView?.removeFromSuperview() + }) } } transition.updateFrame(node: self.contextGestureContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) - if case let .peer(_, _, isArchived) = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 { + if case let .peer(peerId, _, isArchived) = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 { + if peerId == self.context.account.peerId, self.isProfileEmbedded, self.currentStoryFolder != nil { + let emptyStateView: ComponentView + var emptyStateTransition = ComponentTransition(transition) + if let current = self.emptyStateView { + emptyStateView = current + } else { + emptyStateTransition = .immediate + emptyStateView = ComponentView() + self.emptyStateView = emptyStateView + } + + //TODO:localize + let emptyStateSize = emptyStateView.update( + transition: emptyStateTransition, + component: AnyComponent(EmptyStateIndicatorComponent( + context: self.context, + theme: presentationData.theme, + fitToHeight: self.isProfileEmbedded, + animationName: nil, + title: "Organize Your Stories", + text: "Add some stories to this album.", + actionTitle: "Add to Album", + action: { [weak self] in + guard let self else { + return + } + self.presentAddStoriesToFolder() + }, + additionalActionTitle: nil, + additionalAction: {}, + additionalActionSeparator: nil + )), + environment: {}, + containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset) + ) + + let emptyStateFrame: CGRect + if self.isProfileEmbedded { + emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset + 22.0, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize) + } else { + emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: gridTopInset), size: emptyStateSize) + } + + if let emptyStateComponentView = emptyStateView.view { + if emptyStateComponentView.superview == nil { + self.view.addSubview(emptyStateComponentView) + if self.didUpdateItemsOnce { + emptyStateComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + emptyStateTransition.setFrame(view: emptyStateComponentView, frame: emptyStateFrame) + } + + let backgroundColor: UIColor + if self.isProfileEmbedded, case .botPreview = self.scope { + backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else if self.isProfileEmbedded { + backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else { + backgroundColor = presentationData.theme.list.blocksBackgroundColor + } + + if self.didUpdateItemsOnce { + ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut)).setBackgroundColor(view: self.view, color: backgroundColor) + } else { + self.view.backgroundColor = backgroundColor + } + } else { + let emptyStateView: ComponentView + var emptyStateTransition = ComponentTransition(transition) + if let current = self.emptyStateView { + emptyStateView = current + } else { + emptyStateTransition = .immediate + emptyStateView = ComponentView() + self.emptyStateView = emptyStateView + } + let emptyStateSize = emptyStateView.update( + transition: emptyStateTransition, + component: AnyComponent(EmptyStateIndicatorComponent( + context: self.context, + theme: presentationData.theme, + fitToHeight: self.isProfileEmbedded, + animationName: "StoryListEmpty", + title: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Title : presentationData.strings.StoryList_SavedEmptyPosts_Title, + text: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Text : presentationData.strings.StoryList_SavedEmptyPosts_Text, + actionTitle: isArchived ? nil : presentationData.strings.StoryList_SavedAddAction, + action: { [weak self] in + guard let self else { + return + } + self.emptyAction?() + }, + additionalActionTitle: (isArchived || self.isProfileEmbedded) ? nil : presentationData.strings.StoryList_SavedEmptyAction, + additionalAction: { [weak self] in + guard let self else { + return + } + self.additionalEmptyAction?() + } + )), + environment: {}, + containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset) + ) + + let emptyStateFrame: CGRect + if self.isProfileEmbedded { + emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize) + } else { + emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: gridTopInset), size: emptyStateSize) + } + + if let emptyStateComponentView = emptyStateView.view { + if emptyStateComponentView.superview == nil { + self.view.addSubview(emptyStateComponentView) + if self.didUpdateItemsOnce { + emptyStateComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + emptyStateTransition.setFrame(view: emptyStateComponentView, frame: emptyStateFrame) + } + + let backgroundColor: UIColor + if self.isProfileEmbedded { + backgroundColor = presentationData.theme.list.plainBackgroundColor + } else { + backgroundColor = presentationData.theme.list.blocksBackgroundColor + } + + if self.didUpdateItemsOnce { + ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut)).setBackgroundColor(view: self.view, color: backgroundColor) + } else { + self.view.backgroundColor = backgroundColor + } + } + } else if case .botPreview = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 { let emptyStateView: ComponentView var emptyStateTransition = ComponentTransition(transition) if let current = self.emptyStateView { @@ -3946,29 +4340,44 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr emptyStateView = ComponentView() self.emptyStateView = emptyStateView } + + var isMainLanguage = true + if let listSource = self.listSource as? BotPreviewStoryListContext, let _ = listSource.language { + isMainLanguage = false + } + let emptyStateSize = emptyStateView.update( transition: emptyStateTransition, component: AnyComponent(EmptyStateIndicatorComponent( context: self.context, theme: presentationData.theme, fitToHeight: self.isProfileEmbedded, - animationName: "StoryListEmpty", - title: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Title : presentationData.strings.StoryList_SavedEmptyPosts_Title, - text: isArchived ? presentationData.strings.StoryList_ArchivedEmptyState_Text : presentationData.strings.StoryList_SavedEmptyPosts_Text, - actionTitle: isArchived ? nil : presentationData.strings.StoryList_SavedAddAction, + animationName: nil, + title: presentationData.strings.BotPreviews_Empty_Title, + text: presentationData.strings.BotPreviews_Empty_Text(Int32(self.maxBotPreviewCount)), + actionTitle: self.canManageStories ? presentationData.strings.BotPreviews_Empty_Add : nil, action: { [weak self] in guard let self else { return } - self.emptyAction?() + if self.canAddMoreBotPreviews() { + self.emptyAction?() + } else { + self.presentUnableToAddMorePreviewsAlert() + } }, - additionalActionTitle: (isArchived || self.isProfileEmbedded) ? nil : presentationData.strings.StoryList_SavedEmptyAction, + additionalActionTitle: self.canManageStories ? (isMainLanguage ? presentationData.strings.BotPreviews_Empty_AddTranslation : presentationData.strings.BotPreviews_Empty_DeleteTranslation) : nil, additionalAction: { [weak self] in guard let self else { return } - self.additionalEmptyAction?() - } + if isMainLanguage { + self.presentAddBotPreviewLanguage() + } else { + self.presentDeleteBotPreviewLanguage() + } + }, + additionalActionSeparator: self.canManageStories ? presentationData.strings.BotPreviews_Empty_Separator : nil )), environment: {}, containerSize: CGSize(width: size.width, height: size.height - gridTopInset - bottomInset) @@ -3976,7 +4385,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr let emptyStateFrame: CGRect if self.isProfileEmbedded { - emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize) + emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: max(gridTopInset + 22.0, floor((visibleHeight - gridTopInset - bottomInset - emptyStateSize.height) * 0.5))), size: emptyStateSize) } else { emptyStateFrame = CGRect(origin: CGPoint(x: floor((size.width - emptyStateSize.width) * 0.5), y: gridTopInset), size: emptyStateSize) } @@ -3992,8 +4401,10 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } let backgroundColor: UIColor - if self.isProfileEmbedded { - backgroundColor = presentationData.theme.list.plainBackgroundColor + if self.isProfileEmbedded, case .botPreview = self.scope { + backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else if self.isProfileEmbedded { + backgroundColor = presentationData.theme.list.blocksBackgroundColor } else { backgroundColor = presentationData.theme.list.blocksBackgroundColor } @@ -4003,7 +4414,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } else { self.view.backgroundColor = backgroundColor } - } else if case .botPreview = self.scope, let items = self.items, items.items.isEmpty, items.count == 0 { + } else if case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded, let items = self.items, items.items.isEmpty, items.count == 0 { let emptyStateView: ComponentView var emptyStateTransition = ComponentTransition(transition) if let current = self.emptyStateView { @@ -4073,6 +4484,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr let backgroundColor: UIColor if self.isProfileEmbedded, case .botPreview = self.scope { backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else if self.isProfileEmbedded, case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, self.isProfileEmbedded, !isArchived { + backgroundColor = presentationData.theme.list.blocksBackgroundColor } else if self.isProfileEmbedded { backgroundColor = presentationData.theme.list.blocksBackgroundColor } else { @@ -4101,6 +4514,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr if self.isProfileEmbedded, case .botPreview = self.scope { subTransition.setBackgroundColor(view: self.view, color: presentationData.theme.list.blocksBackgroundColor) + } else if self.isProfileEmbedded, case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, !isArchived, self.isProfileEmbedded { + 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 { @@ -4109,6 +4524,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } else { if self.isProfileEmbedded, case .botPreview = self.scope { self.view.backgroundColor = presentationData.theme.list.blocksBackgroundColor + } else if self.isProfileEmbedded, case let .peer(peerId, _, isArchived) = self.scope, peerId == self.context.account.peerId, self.isProfileEmbedded, !isArchived { + self.view.backgroundColor = presentationData.theme.list.blocksBackgroundColor } else { if case let .search(peerId, _) = self.scope, peerId != nil { @@ -4132,6 +4549,8 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr var adjustForSmallCount = true if case .botPreview = self.scope { adjustForSmallCount = false + } else if self.currentStoryFolder != nil { + adjustForSmallCount = false } self.itemGrid.pinchEnabled = items.count > 2 && !self.isReordering @@ -4234,10 +4653,21 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } public func canReorder() -> Bool { - guard let items = self.items else { - return false + if case .botPreview = self.scope { + guard let items = self.items else { + return false + } + return items.count > 1 + } else { + if self.currentStoryFolder == nil { + return false + } + + guard let items = self.items else { + return false + } + return items.count > 1 } - return items.count > 1 } private func presentAddBotPreviewLanguage() { @@ -4250,6 +4680,78 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr })) } + private func presentAddStoryFolder(addItems: [EngineStoryItem] = []) { + //TODO:localize + let promptController = promptController( + sharedContext: self.context.sharedContext, + updatedPresentationData: nil, + text: "Create a New Album", + titleFont: .bold, + subtitle: "Choose a name for your album and start adding your stories there.", + value: "", + placeholder: "Title", + characterLimit: 20, + apply: { [weak self] value in + guard let self else { + return + } + if let value { + if let listSource = self.listSource as? PeerStoryListContext { + listSource.addFolder(title: value, completion: { [weak self] id in + Queue.mainQueue().async { + guard let self, let listSource = self.listSource as? PeerStoryListContext else { + return + } + if !addItems.isEmpty { + listSource.addToFolder(id: id, items: addItems) + } + self.setStoryFolder(id: id, assumeEmpty: addItems.isEmpty) + } + }) + } + } + } + ) + self.parentController?.present(promptController, in: .window(.root)) + } + + private func presentAddStoriesToFolder() { + guard case let .peer(peerId, _, _) = self.scope else { + return + } + guard let folder = self.currentStoryFolder else { + return + } + + let controller = self.context.sharedContext.makeStorySelectionController(context: self.context, peerId: peerId, completion: { [weak self] items in + guard let self else { + return + } + if let listSource = self.listSource as? PeerStoryListContext { + listSource.addToFolder(id: folder.id, items: items) + } + }) + controller.navigationPresentation = .modal + + self.parentController?.push(controller) + } + + public func presentDeleteCurrentStoryFolder() { + if let folder = self.currentStoryFolder { + self.setStoryFolder(id: nil, assumeEmpty: false) + self.currentStoryFolders.removeAll(where: { $0.id == folder.id }) + self.removedStoryFolders.insert(folder.id) + + if let listContext = self.listSource as? PeerStoryListContext { + listContext.removeFolder(id: folder.id) + } + + 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) + } + } + } + public func presentUnableToAddMorePreviewsAlert() { self.parentController?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: self.presentationData), title: nil, text: self.presentationData.strings.BotPreviews_AlertTooManyPreviews(Int32(self.maxBotPreviewCount)), actions: [ TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: { @@ -4335,12 +4837,32 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } if let id { - if let cachedListSource = self.cachedListSources[id] { + if let cachedListSource = self.cachedListSources[AnyHashable(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 + self.cachedListSources[AnyHashable(id)] = listSource + } + } else { + self.listSource = self.defaultListSource + } + + self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: true) + } + + private func setStoryFolder(id: Int64?, assumeEmpty: Bool) { + if let listSource = self.listSource as? PeerStoryListContext, listSource.folderId == id { + return + } + + if let id { + if let cachedListSource = self.cachedListSources[AnyHashable(id)] { + self.listSource = cachedListSource + } else { + let listSource = PeerStoryListContext(account: self.context.account, peerId: self.context.account.peerId, isArchived: false, folderId: id) + self.listSource = listSource + //self.cachedListSources[AnyHashable(id)] = listSource } } else { self.listSource = self.defaultListSource @@ -4388,20 +4910,26 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr 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 { - inner: for item in items.items { - if let item = item as? VisualMediaItem { - if item.storyId == id { - if item.isPinned { - updatedPinnedIds.append(id.id) - break inner + if let _ = self.currentStoryFolder { + if let listSource = self.listSource as? PeerStoryListContext { + listSource.reorderItemsInFolder(itemIds: reorderedIds.map { $0.id }) + } + } else { + var updatedPinnedIds: [Int32] = [] + for id in reorderedIds { + inner: for item in items.items { + if let item = item as? VisualMediaItem { + if item.storyId == id { + if item.isPinned { + updatedPinnedIds.append(id.id) + break inner + } } } } } + let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: id, ids: updatedPinnedIds).startStandalone() } - let _ = self.context.engine.messages.updatePinnedToTopStories(peerId: id, ids: updatedPinnedIds).startStandalone() } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 540e1b1bc4..fa48c5791b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -99,6 +99,8 @@ swift_library( "//submodules/TelegramUI/Components/InteractiveTextComponent", "//submodules/TelegramUI/Components/SaveProgressScreen", "//submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController", + "//submodules/DirectMediaImageCache", + "//submodules/PromptUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index dc2b68b825..5c5eddbd06 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift @@ -1456,6 +1456,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } private let context: AccountContext + let listContext: StoryListContext public private(set) var stateValue: StoryContentContextState? public var state: Signal { @@ -1486,6 +1487,7 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { public init(context: AccountContext, listContext: StoryListContext, initialId: StoryId?, splitIndexIntoDays: Bool) { self.context = context + self.listContext = listContext let preferHighQualityStories: Signal = combineLatest( context.sharedContext.automaticMediaDownloadSettings diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index f1629ae208..b22b6aefb3 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -1688,6 +1688,39 @@ private final class StoryContainerScreenComponent: Component { performReorderAction?() }) }, + createToFolder: { [weak self] title, items in + guard let self, let component = self.component else { + return + } + + if let content = component.content as? PeerStoryListContentContextImpl { + if let listSource = content.listContext as? PeerStoryListContext { + listSource.addFolder(title: title, completion: { [weak listSource] id in + Queue.mainQueue().async { + guard let listSource else { + return + } + if !items.isEmpty { + listSource.addToFolder(id: id, items: items) + } + } + }) + } + } + }, + addToFolder: { [weak self] folderId in + guard let self, let component = self.component else { + return + } + guard let stateValue = self.stateValue, let slice = stateValue.slice else { + return + } + if let content = component.content as? PeerStoryListContentContextImpl { + if let listSource = content.listContext as? PeerStoryListContext { + listSource.addToFolder(id: folderId, items: [slice.item.storyItem]) + } + } + }, controller: { [weak self] in return self?.environment?.controller() }, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 856df6d79b..075a35744c 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -44,6 +44,8 @@ import StoryFooterPanelComponent import TelegramNotices import SliderContextItem import SaveProgressScreen +import DirectMediaImageCache +import PromptUI public final class StoryAvailableReactions: Equatable { let reactionItems: [ReactionItem] @@ -120,6 +122,8 @@ public final class StoryItemSetContainerComponent: Component { public let delete: () -> Void public let markAsSeen: (StoryId) -> Void public let reorder: () -> Void + public let createToFolder: (String, [EngineStoryItem]) -> Void + public let addToFolder: (Int64) -> Void public let controller: () -> ViewController? public let toggleAmbientMode: () -> Void public let keyboardInputData: Signal @@ -157,6 +161,8 @@ public final class StoryItemSetContainerComponent: Component { delete: @escaping () -> Void, markAsSeen: @escaping (StoryId) -> Void, reorder: @escaping () -> Void, + createToFolder: @escaping (String, [EngineStoryItem]) -> Void, + addToFolder: @escaping (Int64) -> Void, controller: @escaping () -> ViewController?, toggleAmbientMode: @escaping () -> Void, keyboardInputData: Signal, @@ -193,6 +199,8 @@ public final class StoryItemSetContainerComponent: Component { self.delete = delete self.markAsSeen = markAsSeen self.reorder = reorder + self.createToFolder = createToFolder + self.addToFolder = addToFolder self.controller = controller self.toggleAmbientMode = toggleAmbientMode self.keyboardInputData = keyboardInputData @@ -6101,6 +6109,102 @@ public final class StoryItemSetContainerComponent: Component { var items: [ContextMenuItem] = [] + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Add to Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in + guard let self, let c else { + f(.default) + return + } + + Task { @MainActor [weak self, weak c] in + guard let self, let component = self.component, let peerId = component.slice.item.peerId, let c else { + return + } + + let (peerReference, folderPreviews) = await PeerStoryListContext.folderPreviews(peerId: peerId, account: component.context.account).get() + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { c ,f in + c?.popItems() + }))) + items.append(.separator) + + items.append(.action(ContextMenuActionItem(text: "New Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { [weak self] c, f in + guard let self else { + f(.default) + return + } + + c?.dismiss(completion: { [weak self] in + guard let self, let component = self.component else { + return + } + self.presentAddStoryFolder(addItems: [component.slice.item.storyItem]) + }) + }))) + + for folderPreview in folderPreviews { + var iconSource: ContextMenuActionItemIconSource? + if let story = folderPreview.item { + var imageSignal: Signal? + + var selectedMedia: Media? + if let image = story.media._asMedia() as? TelegramMediaImage { + selectedMedia = image + } else if let file = story.media._asMedia() as? TelegramMediaFile { + selectedMedia = file + } + + if let selectedMedia { + let directMediaImageCache = DirectMediaImageCache(account: component.context.account) + if let result = directMediaImageCache.getImage(peer: peerReference, story: story, media: selectedMedia, width: 24, aspectRatio: 1.0, possibleWidths: [24], includeBlurred: false, synchronous: true) { + if let loadSignal = result.loadSignal { + imageSignal = .single(result.image) |> then(loadSignal) + } else { + imageSignal = .single(result.image) + } + } + } + + if let imageSignal { + iconSource = ContextMenuActionItemIconSource( + size: CGSize(width: 24.0, height: 24.0), + cornerRadius: 5.0, + signal: imageSignal + ) + } + } + + var icon: (PresentationTheme) -> UIImage? = { _ in nil } + if iconSource == nil { + icon = { theme in + return generateImage(CGSize(width: 24.0, height: 24.0), opaque: false, scale: nil, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(theme.contextMenu.primaryColor.withMultipliedAlpha(0.1).cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 5.0).cgPath) + context.fillPath() + }) + } + } + + items.append(.action(ContextMenuActionItem(text: folderPreview.folder.title, icon: icon, iconSource: iconSource, iconPosition: .left, action: { [weak self] c, f in + guard let self, let component = self.component else { + f(.default) + return + } + + c?.dismiss(completion: {}) + + component.addToFolder(folderPreview.folder.id) + }))) + } + + c.pushItems(items: .single(ContextController.Items(content: .list(items)))) + } + }))) + if case .file = component.slice.item.storyItem.media { var speedValue: String = presentationData.strings.PlaybackSpeed_Normal var speedIconText: String = "1x" @@ -7028,6 +7132,34 @@ public final class StoryItemSetContainerComponent: Component { }) } + private func presentAddStoryFolder(addItems: [EngineStoryItem] = []) { + guard let component = self.component else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: component.theme) + + let promptController = promptController( + sharedContext: component.context.sharedContext, + updatedPresentationData: (initial: presentationData, signal: .single(presentationData)), + text: "Create a New Album", + titleFont: .bold, + subtitle: "Choose a name for your album and start adding your stories there.", + value: "", + placeholder: "Title", + characterLimit: 20, + apply: { [weak self] value in + guard let self, let component = self.component else { + return + } + if let value { + component.createToFolder(value, addItems) + } + } + ) + component.presentController(promptController, nil) + } + func displayMutedVideoTooltip() { guard let component = self.component else { return diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 7530859b59..2a81f9f50b 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2546,6 +2546,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { return PeerInfoStoryGridScreen(context: context, peerId: context.account.peerId, scope: isArchive ? .archive : .saved) } + public func makeStorySelectionController(context: AccountContext, peerId: EnginePeer.Id, completion: @escaping ([EngineStoryItem]) -> Void) -> ViewController { + return PeerInfoStoryGridScreen(context: context, peerId: peerId, scope: .saved, selectionModeCompletion: completion) + } + public func makeArchiveSettingsController(context: AccountContext) -> ViewController { return archiveSettingsController(context: context) } diff --git a/submodules/TooltipUI/BUILD b/submodules/TooltipUI/BUILD index a747e4fd7c..bdea46ce1f 100644 --- a/submodules/TooltipUI/BUILD +++ b/submodules/TooltipUI/BUILD @@ -28,6 +28,7 @@ swift_library( "//submodules/Components/MultilineTextWithEntitiesComponent", "//submodules/Components/MultilineTextComponent", "//submodules/ShimmerEffect", + "//submodules/UIKitRuntimeUtils", ], visibility = [ "//visibility:public", diff --git a/submodules/TooltipUI/Sources/TooltipScreen.swift b/submodules/TooltipUI/Sources/TooltipScreen.swift index 510c3c53ac..0be12a18fc 100644 --- a/submodules/TooltipUI/Sources/TooltipScreen.swift +++ b/submodules/TooltipUI/Sources/TooltipScreen.swift @@ -20,6 +20,7 @@ import BalancedTextComponent import MultilineTextComponent import MultilineTextWithEntitiesComponent import ShimmerEffect +import UIKitRuntimeUtils public enum TooltipActiveTextItem { case url(String, Bool) @@ -1328,6 +1329,10 @@ public final class TooltipScreen: ViewController { override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + if self.ignoreAppearanceMethodInvocations() { + return + } + self.controllerNode.animateIn() self.resetDismissTimeout(duration: self.displayDuration) } diff --git a/third-party/XcodeGen b/third-party/XcodeGen new file mode 160000 index 0000000000..53cb43cb66 --- /dev/null +++ b/third-party/XcodeGen @@ -0,0 +1 @@ +Subproject commit 53cb43cb66908a28812d7629d03fed94c9827a24