diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 13c0443d3c..35344f4193 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -7541,9 +7541,6 @@ Sorry for the inconvenience."; "Premium.Stickers.Description" = "Unlock this sticker and more by subscribing to Telegram Premium."; "Premium.Stickers.Proceed" = "Unlock Premium Stickers"; -"Premium.Reactions.Description" = "Unlock additional reactions by subscribing to Telegram Premium."; -"Premium.Reactions.Proceed" = "Unlock Additional Reactions"; - "AccessDenied.LocationPreciseDenied" = "To share your specific location in this chat, please go to Settings > Privacy > Location Services > Telegram and set Precise Location to On."; "Chat.MultipleTypingPair" = "%@ and %@"; @@ -7557,11 +7554,12 @@ Sorry for the inconvenience."; "OldChannels.LeaveCommunities_1" = "Leave %@ Community"; "OldChannels.LeaveCommunities_any" = "Leave %@ Communities"; +"Premium.FileTooLarge" = "File Too Large"; "Premium.LimitReached" = "Limit Reached"; "Premium.IncreaseLimit" = "Increase Limit"; -"Premium.MaxFoldersCountText" = "You have reached the limit of **%@** folders. You can double the limit to **%@** folders by subscribing to **Telegram Premium**."; -"Premium.MaxChatsInFolderCountText" = "Sorry, you can't add more than **%@** chats to a folder. You can increase this limit to **%@** by upgrading to **Telegram Premium**."; +"Premium.MaxFoldersCountText" = "You have reached the limit of **%1$@** folders. You can double the limit to **%2$@** folders by subscribing to **Telegram Premium**."; +"Premium.MaxChatsInFolderCountText" = "Sorry, you can't add more than **%1$@** chats to a folder. You can increase this limit to **%2$@** by upgrading to **Telegram Premium**."; "Premium.MaxFileSizeText" = "Double this limit to %@ per file by subscribing to **Telegram Premium**."; "Premium.MaxPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some of the currently pinned ones or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; "Premium.MaxFavedStickersTitle" = "The Limit of %@ Stickers Reached"; @@ -7573,6 +7571,12 @@ Sorry for the inconvenience."; "Premium.Title" = "Telegram Premium"; "Premium.Description" = "Go **beyond the limits**, get **exclusive features** and support us by subscribing to **Telegram Premium**."; +"Premium.PersonalTitle" = "%@ is a subscriber of Telegram Premium"; +"Premium.PersonalDescription" = "Owners of **Telegram Premium** accounts have exclusive access to multiple additional features."; + +"Premium.SubscribedTitle" = "You are all set!"; +"Premium.SubscribedDescription" = "Thank you for subsribing to **Telegram Premium**. Here's what is now unlocked."; + "Premium.DoubledLimits" = "Doubled Limits"; "Premium.DoubledLimitsInfo" = "Up to 1000 channels, 20 folders, 10 pins, 20 public links, 4 accounts and more."; @@ -7610,6 +7614,8 @@ Sorry for the inconvenience."; "Premium.Terms" = "By purchasing a Premium subscription, you agree to our [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.MoreAboutPremium" = "More About Premium"; + "Conversation.CopyProtectionSavingDisabledSecret" = "Saving is restricted"; "Conversation.CopyProtectionForwardingDisabledSecret" = "Forwards are restricted"; @@ -7622,3 +7628,5 @@ Sorry for the inconvenience."; "Conversation.PremiumUploadFileTooLarge" = "File could not be sent, because it is larger than 4 GB.\n\nYou can send as many files as you like, but each must be smaller than 4 GB."; "SponsoredMessageMenu.Hide" = "Hide"; + +"ChatListFolder.MaxChatsInFolder" = "Sorry, you can't add more than %d chats to a folder."; diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index de43468a11..ba13d770d6 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -244,6 +244,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var emojiViewProvider: ((String) -> UIView)? + private var maxCaptionLength: Int32? + public init(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, isCaption: Bool = false, isAttachment: Bool = false, presentController: @escaping (ViewController) -> Void) { self.context = context self.presentationInterfaceState = presentationInterfaceState @@ -323,6 +325,23 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } self.updateSendButtonEnabled(isCaption || isAttachment, animated: false) + + if self.isCaption || self.isAttachment { + let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) + |> mapToSignal { peer -> Signal in + if let peer = peer { + return self.context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.UserLimits.init(isPremium: peer.isPremium)) + |> map { limits in + return limits.maxCaptionLengthCount + } + } else { + return .complete() + } + } + |> deliverOnMainQueue).start(next: { [weak self] maxCaptionLength in + self?.maxCaptionLength = maxCaptionLength + }) + } } public var sendPressed: ((NSAttributedString?) -> Void)? @@ -931,8 +950,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private func updateCounterTextNode(transition: ContainedViewLayoutTransition) { let inputTextMaxLength: Int32? - if self.isCaption || self.isAttachment { - inputTextMaxLength = self.context.currentLimitsConfiguration.with { $0 }.maxMediaCaptionLength + if let maxCaptionLength = self.maxCaptionLength { + inputTextMaxLength = maxCaptionLength } else { inputTextMaxLength = nil } @@ -1301,8 +1320,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS @objc func sendButtonPressed() { let inputTextMaxLength: Int32? - if self.isCaption || self.isAttachment { - inputTextMaxLength = self.context.currentLimitsConfiguration.with { $0 }.maxMediaCaptionLength + if let maxCaptionLength = self.maxCaptionLength { + inputTextMaxLength = maxCaptionLength } else { inputTextMaxLength = nil } diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 34a5f5ec82..7b63309cb4 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1461,7 +1461,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController tabContextGesture(id, sourceNode, gesture, true) } - self.ready.set(self.chatListDisplayNode.containerNode.ready) + if case .group = self.groupId { + self.ready.set(self.chatListDisplayNode.containerNode.ready) + } else { + self.ready.set(.never()) + } self.displayNodeDidLoad() } @@ -1976,8 +1980,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } - let isPremium = peerView.peers[peerView.peerId]?.isPremium ?? false - strongSelf.isPremium = isPremium + let isPremium = peerView.peers[peerView.peerId]?.isPremium + strongSelf.isPremium = isPremium ?? false let (_, items) = countAndFilterItems var filterItems: [ChatListFilterTabEntry] = [] @@ -1985,7 +1989,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController for (filter, unreadCount, hasUnmutedUnread) in items { switch filter { case .allChats: - if !isPremium && filterItems.count > 0 { + if let isPremium = isPremium, !isPremium && filterItems.count > 0 { filterItems.insert(.all(unreadCount: 0), at: 0) } else { filterItems.append(.all(unreadCount: 0)) @@ -2038,7 +2042,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController selectedEntryId = .all } } - let filtersLimit = !isPremium ? limits.maxFoldersCount : nil + let filtersLimit = isPremium == false ? limits.maxFoldersCount : nil strongSelf.tabContainerData = (resolvedItems, displayTabsAtBottom, filtersLimit) var availableFilters: [ChatListContainerNodeFilter] = [] var hasAllChats = false @@ -2046,7 +2050,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController switch item.0 { case .allChats: hasAllChats = true - if !isPremium && availableFilters.count > 0 { + if let isPremium = isPremium, !isPremium && availableFilters.count > 0 { availableFilters.insert(.all, at: 0) } else { availableFilters.append(.all) @@ -2060,10 +2064,20 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } strongSelf.chatListDisplayNode.containerNode.updateAvailableFilters(availableFilters, limit: filtersLimit) - if !strongSelf.initializedFilters && selectedEntryId != strongSelf.chatListDisplayNode.containerNode.currentItemFilter { - strongSelf.chatListDisplayNode.containerNode.switchToFilter(id: selectedEntryId, animated: false, completion: nil) + if isPremium == nil && items.isEmpty { + strongSelf.ready.set(strongSelf.chatListDisplayNode.containerNode.currentItemNode.ready) + } else if !strongSelf.initializedFilters { + if selectedEntryId != strongSelf.chatListDisplayNode.containerNode.currentItemFilter { + strongSelf.chatListDisplayNode.containerNode.switchToFilter(id: selectedEntryId, animated: false, completion: { [weak self] in + if let strongSelf = self { + strongSelf.ready.set(strongSelf.chatListDisplayNode.containerNode.currentItemNode.ready) + } + }) + } else { + strongSelf.ready.set(strongSelf.chatListDisplayNode.containerNode.currentItemNode.ready) + } + strongSelf.initializedFilters = true } - strongSelf.initializedFilters = true let isEmpty = resolvedItems.count <= 1 || displayTabsAtBottom @@ -2083,7 +2097,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController strongSelf.containerLayoutUpdated(layout, transition: transition) (strongSelf.parent as? TabBarController)?.updateLayout(transition: transition) } else { - strongSelf.tabContainerNode.update(size: CGSize(width: layout.size.width, height: 46.0), sideInset: layout.safeInsets.left, filters: resolvedItems, selectedFilter: selectedEntryId, isReordering: strongSelf.chatListDisplayNode.isReorderingFilters || (strongSelf.chatListDisplayNode.containerNode.currentItemNode.currentState.editing && !strongSelf.chatListDisplayNode.didBeginSelectingChatsWhileEditing), isEditing: strongSelf.chatListDisplayNode.containerNode.currentItemNode.currentState.editing, canReorderAllChats: isPremium, filtersLimit: filtersLimit, transitionFraction: strongSelf.chatListDisplayNode.containerNode.transitionFraction, presentationData: strongSelf.presentationData, transition: .animated(duration: 0.4, curve: .spring)) + strongSelf.tabContainerNode.update(size: CGSize(width: layout.size.width, height: 46.0), sideInset: layout.safeInsets.left, filters: resolvedItems, selectedFilter: selectedEntryId, isReordering: strongSelf.chatListDisplayNode.isReorderingFilters || (strongSelf.chatListDisplayNode.containerNode.currentItemNode.currentState.editing && !strongSelf.chatListDisplayNode.didBeginSelectingChatsWhileEditing), isEditing: strongSelf.chatListDisplayNode.containerNode.currentItemNode.currentState.editing, canReorderAllChats: strongSelf.isPremium, filtersLimit: filtersLimit, transitionFraction: strongSelf.chatListDisplayNode.containerNode.transitionFraction, presentationData: strongSelf.presentationData, transition: .animated(duration: 0.4, curve: .spring)) strongSelf.chatListDisplayNode.inlineTabContainerNode.update(size: CGSize(width: layout.size.width, height: 40.0), sideInset: layout.safeInsets.left, filters: resolvedItems, selectedFilter: selectedEntryId, isReordering: strongSelf.chatListDisplayNode.isReorderingFilters || (strongSelf.chatListDisplayNode.containerNode.currentItemNode.currentState.editing && !strongSelf.chatListDisplayNode.didBeginSelectingChatsWhileEditing), isEditing: false, transitionFraction: strongSelf.chatListDisplayNode.containerNode.transitionFraction, presentationData: strongSelf.presentationData, transition: .animated(duration: 0.4, curve: .spring)) } } @@ -3236,15 +3250,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController let _ = (combineLatest(queue: .mainQueue(), self.context.engine.peers.currentChatListFilters(), chatListFilterItems(context: self.context) - |> take(1) + |> take(1), + context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId), + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false), + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: true) + ) ) - |> deliverOnMainQueue).start(next: { [weak self] presetList, filterItemsAndTotalCount in + |> deliverOnMainQueue).start(next: { [weak self] presetList, filterItemsAndTotalCount, result in guard let strongSelf = self else { return } - let _ = strongSelf.context.engine.peers.markChatListFeaturedFiltersAsSeen().start() + let (accountPeer, limits, _) = result + let isPremium = accountPeer?.isPremium ?? false + + let _ = strongSelf.context.engine.peers.markChatListFeaturedFiltersAsSeen().start() let (_, filterItems) = filterItemsAndTotalCount var items: [ContextMenuItem] = [] @@ -3272,11 +3294,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } if !presetList.isEmpty { - items.append(.separator) - + if presetList.count > 1 { + items.append(.separator) + } + var filterCount = 0 for case let .filter(id, title, _, data) in presetList { let filterType = chatListFilterType(data) var badge: ContextMenuActionBadge? + var isDisabled = false + if !isPremium && filterCount >= limits.maxFoldersCount { + isDisabled = true + } + for item in filterItems { if item.0.id == id && item.1 != 0 { badge = ContextMenuActionBadge(value: "\(item.1)", color: item.2 ? .accent : .inactive) @@ -3284,23 +3313,27 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } items.append(.action(ContextMenuActionItem(text: title, badge: badge, icon: { theme in let imageName: String - switch filterType { - case .generic: - imageName = "Chat/Context Menu/List" - case .unmuted: - imageName = "Chat/Context Menu/Unmute" - case .unread: - imageName = "Chat/Context Menu/MarkAsUnread" - case .channels: - imageName = "Chat/Context Menu/Channels" - case .groups: - imageName = "Chat/Context Menu/Groups" - case .bots: - imageName = "Chat/Context Menu/Bots" - case .contacts: - imageName = "Chat/Context Menu/User" - case .nonContacts: - imageName = "Chat/Context Menu/UnknownUser" + if isDisabled { + imageName = "Chat/Context Menu/Lock" + } else { + switch filterType { + case .generic: + imageName = "Chat/Context Menu/List" + case .unmuted: + imageName = "Chat/Context Menu/Unmute" + case .unread: + imageName = "Chat/Context Menu/MarkAsUnread" + case .channels: + imageName = "Chat/Context Menu/Channels" + case .groups: + imageName = "Chat/Context Menu/Groups" + case .bots: + imageName = "Chat/Context Menu/Bots" + case .contacts: + imageName = "Chat/Context Menu/User" + case .nonContacts: + imageName = "Chat/Context Menu/UnknownUser" + } } return generateTintedImage(image: UIImage(bundleImageName: imageName), color: theme.contextMenu.primaryColor) }, action: { _, f in @@ -3308,8 +3341,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let strongSelf = self else { return } - strongSelf.selectTab(id: .filter(id)) + if isDisabled { + let context = strongSelf.context + var replaceImpl: ((ViewController) -> Void)? + let controller = PremiumLimitScreen(context: context, subject: .folders, count: strongSelf.tabContainerNode.filtersCount, action: { + let controller = PremiumIntroScreen(context: context, source: .folders) + replaceImpl?(controller) + }) + replaceImpl = { [weak controller] c in + controller?.replace(with: c) + } + strongSelf.push(controller) + } else { + strongSelf.selectTab(id: .filter(id)) + } }))) + + filterCount += 1 } } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index a3e3e0eb26..f5abfabcf9 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -878,20 +878,34 @@ final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDelegate { let disposable = MetaDisposable() self.pendingItemNode = (id, itemNode, disposable) - disposable.set((combineLatest(itemNode.listNode.ready, self.validLayoutReady) - |> filter { $0 && $1 } + if !animated { + self.selectedId = id + self.applyItemNodeAsCurrent(id: id, itemNode: itemNode) + self.currentItemFilterUpdated?(self.currentItemFilter, self.transitionFraction, .immediate, false) + } + + disposable.set((itemNode.listNode.ready |> take(1) |> deliverOnMainQueue).start(next: { [weak self, weak itemNode] _ in guard let strongSelf = self, let itemNode = itemNode, itemNode === strongSelf.pendingItemNode?.1 else { return } - guard let (layout, navigationBarHeight, visualNavigationHeight, cleanNavigationBarHeight, isReorderingFilters, isEditing) = strongSelf.validLayout else { - return - } + strongSelf.pendingItemNode = nil - let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.35, curve: .spring) : .immediate + guard let (layout, navigationBarHeight, visualNavigationHeight, cleanNavigationBarHeight, isReorderingFilters, isEditing) = strongSelf.validLayout else { + strongSelf.itemNodes[id] = itemNode + strongSelf.addSubnode(itemNode) + + strongSelf.selectedId = id + strongSelf.applyItemNodeAsCurrent(id: id, itemNode: itemNode) + strongSelf.currentItemFilterUpdated?(strongSelf.currentItemFilter, strongSelf.transitionFraction, .immediate, false) + + completion?() + return + } + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.35, curve: .spring) : .immediate if let previousIndex = strongSelf.availableFilters.firstIndex(where: { $0.id == strongSelf.selectedId }), let index = strongSelf.availableFilters.firstIndex(where: { $0.id == id }) { let previousId = strongSelf.selectedId let offsetDirection: CGFloat = index < previousIndex ? 1.0 : -1.0 diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift index 123e201b0a..86e515a944 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift @@ -601,14 +601,22 @@ private func internalChatListFilterAddChatsController(context: AccountContext, f let controller = context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: context, mode: .chatSelection(title: presentationData.strings.ChatListFolder_IncludeChatsTitle, selectedChats: Set(filterData.includePeers.peers), additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories), chatListFilters: allFilters), options: [], filters: [], alwaysEnabled: true, limit: 100)) controller.navigationPresentation = .modal - let _ = (controller.result - |> take(1) - |> deliverOnMainQueue).start(next: { [weak controller] result in + let _ = combineLatest( + queue: Queue.mainQueue(), + controller.result |> take(1), + context.engine.data.get( + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false), + TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: true) + ) + ) + .start(next: { [weak controller] result, data in guard case let .result(peerIds, additionalCategoryIds) = result else { controller?.dismiss() return } + let (limits, premiumLimits) = data + var includePeers: [PeerId] = [] for peerId in peerIds { switch peerId { @@ -620,6 +628,26 @@ private func internalChatListFilterAddChatsController(context: AccountContext, f } includePeers.sort() + if includePeers.count > limits.maxFolderChatsCount { + if includePeers.count > premiumLimits.maxFolderChatsCount { + let alertController = textAlertController(context: context, title: nil, text: presentationData.strings.ChatListFolder_MaxChatsInFolder(Int(premiumLimits.maxFolderChatsCount)).string, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) + controller?.present(alertController, in: .window(.root)) + return + } + + var replaceImpl: ((ViewController) -> Void)? + let limitController = PremiumLimitScreen(context: context, subject: .chatsInFolder, count: Int32(includePeers.count), action: { + let introController = PremiumIntroScreen(context: context, source: .chatsPerFolder) + replaceImpl?(introController) + }) + replaceImpl = { [weak controller] c in + controller?.replace(with: c) + } + controller?.push(limitController) + + return + } + var categories: ChatListFilterPeerCategories = [] for id in additionalCategoryIds { if let index = categoryMapping.firstIndex(where: { $0.1.rawValue == id }) { diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift index c16f0865e3..acc88993e1 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift @@ -74,7 +74,7 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry { case suggestedPreset(index: PresetIndex, title: String, label: String, preset: ChatListFilterData) case suggestedAddCustom(String) case listHeader(String) - case preset(index: PresetIndex, title: String, label: String, preset: ChatListFilter, canBeReordered: Bool, canBeDeleted: Bool, isEditing: Bool, isAllChats: Bool) + case preset(index: PresetIndex, title: String, label: String, preset: ChatListFilter, canBeReordered: Bool, canBeDeleted: Bool, isEditing: Bool, isAllChats: Bool, isDisabled: Bool) case addItem(text: String, isEditing: Bool) case listFooter(String) @@ -95,7 +95,7 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry { return 0 case .listHeader: return 100 - case let .preset(index, _, _, _, _, _, _, _): + case let .preset(index, _, _, _, _, _, _, _, _): return 101 + index.value case .addItem: return 1000 @@ -122,7 +122,7 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry { return .suggestedAddCustom case .listHeader: return .listHeader - case let .preset(_, _, _, preset, _, _, _, _): + case let .preset(_, _, _, preset, _, _, _, _, _): return .preset(preset.id) case .addItem: return .addItem @@ -152,8 +152,8 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry { }) case let .listHeader(text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, multiline: true, sectionId: self.section) - case let .preset(_, title, label, preset, canBeReordered, canBeDeleted, isEditing, isAllChats): - return ChatListFilterPresetListItem(presentationData: presentationData, preset: preset, title: title, label: label, editing: ChatListFilterPresetListItemEditing(editable: true, editing: isEditing, revealed: false), canBeReordered: canBeReordered, canBeDeleted: canBeDeleted, isAllChats: isAllChats, sectionId: self.section, action: { + case let .preset(_, title, label, preset, canBeReordered, canBeDeleted, isEditing, isAllChats, isDisabled): + return ChatListFilterPresetListItem(presentationData: presentationData, preset: preset, title: title, label: label, editing: ChatListFilterPresetListItemEditing(editable: true, editing: isEditing, revealed: false), canBeReordered: canBeReordered, canBeDeleted: canBeDeleted, isAllChats: isAllChats, isDisabled: isDisabled, sectionId: self.section, action: { arguments.openPreset(preset) }, setItemWithRevealedOptions: { lhs, rhs in arguments.setItemWithRevealedOptions(lhs, rhs) @@ -219,10 +219,10 @@ private func chatListFilterPresetListControllerEntries(presentationData: Present for (filter, chatCount) in filtersWithAppliedOrder(filters: filters, order: updatedFilterOrder) { if isPremium, case .allChats = filter { - entries.append(.preset(index: PresetIndex(value: entries.count), title: "", label: "", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: false, isEditing: state.isEditing, isAllChats: true)) + entries.append(.preset(index: PresetIndex(value: entries.count), title: "", label: "", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: false, isEditing: state.isEditing, isAllChats: true, isDisabled: false)) } if case let .filter(_, title, _, _) = filter { - entries.append(.preset(index: PresetIndex(value: entries.count), title: title, label: chatCount == 0 ? "" : "\(chatCount)", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: true, isEditing: state.isEditing, isAllChats: false)) + entries.append(.preset(index: PresetIndex(value: entries.count), title: title, label: chatCount == 0 ? "" : "\(chatCount)", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: true, isEditing: state.isEditing, isAllChats: false, isDisabled: false)) } } if actualFilters.count < limits.maxFoldersCount { @@ -528,7 +528,7 @@ public func chatListFilterPresetListController(context: AccountContext, mode: Ch } controller.setReorderEntry({ (fromIndex: Int, toIndex: Int, entries: [ChatListFilterPresetListEntry]) -> Signal in let fromEntry = entries[fromIndex] - guard case let .preset(_, _, _, fromPreset, _, _, _, _) = fromEntry else { + guard case let .preset(_, _, _, fromPreset, _, _, _, _, _) = fromEntry else { return .single(false) } var referenceFilter: ChatListFilter? @@ -536,7 +536,7 @@ public func chatListFilterPresetListController(context: AccountContext, mode: Ch var afterAll = false if toIndex < entries.count { switch entries[toIndex] { - case let .preset(_, _, _, preset, _, _, _, _): + case let .preset(_, _, _, preset, _, _, _, _, _): referenceFilter = preset default: if entries[toIndex] < fromEntry { diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift index 9e8ba9da0c..1489d68b6d 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift @@ -24,6 +24,7 @@ final class ChatListFilterPresetListItem: ListViewItem, ItemListItem { let canBeReordered: Bool let canBeDeleted: Bool let isAllChats: Bool + let isDisabled: Bool let sectionId: ItemListSectionId let action: () -> Void let setItemWithRevealedOptions: (Int32?, Int32?) -> Void @@ -38,6 +39,7 @@ final class ChatListFilterPresetListItem: ListViewItem, ItemListItem { canBeReordered: Bool, canBeDeleted: Bool, isAllChats: Bool, + isDisabled: Bool, sectionId: ItemListSectionId, action: @escaping () -> Void, setItemWithRevealedOptions: @escaping (Int32?, Int32?) -> Void, @@ -51,6 +53,7 @@ final class ChatListFilterPresetListItem: ListViewItem, ItemListItem { self.canBeReordered = canBeReordered self.canBeDeleted = canBeDeleted self.isAllChats = isAllChats + self.isDisabled = isDisabled self.sectionId = sectionId self.action = action self.setItemWithRevealedOptions = setItemWithRevealedOptions @@ -380,7 +383,7 @@ private final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemN if let arrowImage = strongSelf.arrowNode.image { strongSelf.arrowNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 7.0 - arrowImage.size.width + revealOffset, y: floorToScreenPixels((layout.contentSize.height - arrowImage.size.height) / 2.0)), size: arrowImage.size) } - strongSelf.arrowNode.isHidden = item.isAllChats + strongSelf.arrowNode.isHidden = item.isAllChats || item.isDisabled strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 0.0), size: CGSize(width: params.width - params.rightInset - 56.0 - (leftInset + revealOffset + editingOffset), height: layout.contentSize.height)) diff --git a/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift b/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift index 3c2f5af173..38803090f6 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterTabContainerNode.swift @@ -239,7 +239,7 @@ private final class ItemNode: ASDisplayNode { }) } - transition.updateAlpha(node: self.badgeContainerNode, alpha: (isDisabled || isReordering || unreadCount == 0) ? 0.0 : 1.0) + transition.updateAlpha(node: self.badgeContainerNode, alpha: (isEditing || isDisabled || isReordering || unreadCount == 0) ? 0.0 : 1.0) let selectionAlpha: CGFloat = selectionFraction * selectionFraction let deselectionAlpha: CGFloat = isDisabled ? 0.5 : 1.0// - selectionFraction @@ -307,7 +307,7 @@ private final class ItemNode: ASDisplayNode { self.badgeTextNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((badgeBackgroundFrame.width - badgeSize.width) / 2.0), y: floor((badgeBackgroundFrame.height - badgeSize.height) / 2.0)), size: badgeSize) let width: CGFloat - if self.unreadCount == 0 || self.isReordering || self.isEditing { + if self.unreadCount == 0 || self.isReordering || self.isEditing || self.isDisabled { if !self.isReordering { self.badgeContainerNode.alpha = 0.0 } diff --git a/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift b/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift index bc9501cdf8..cb6d2f4be7 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift @@ -207,7 +207,7 @@ func preparedChatListNodeViewTransition(from fromView: ChatListNodeView?, to toV } var adjustScrollToFirstItem = false - if !previewing && !searchMode && fromEmptyView && scrollToItem == nil && toView.filteredEntries.count >= 1 { + if !previewing && !searchMode && fromEmptyView && scrollToItem == nil && toView.filteredEntries.count >= 2 { adjustScrollToFirstItem = true } diff --git a/submodules/ComponentFlow/Source/Components/List.swift b/submodules/ComponentFlow/Source/Components/List.swift index b73b6d7efd..be3eeef286 100644 --- a/submodules/ComponentFlow/Source/Components/List.swift +++ b/submodules/ComponentFlow/Source/Components/List.swift @@ -2,13 +2,20 @@ import Foundation import UIKit public final class List: CombinedComponent { + public enum Direction { + case horizontal + case vertical + } + public typealias EnvironmentType = ChildEnvironment private let items: [AnyComponentWithIdentity] + private let direction: Direction private let appear: Transition.Appear - public init(_ items: [AnyComponentWithIdentity], appear: Transition.Appear = .default()) { + public init(_ items: [AnyComponentWithIdentity], direction: Direction = .vertical, appear: Transition.Appear = .default()) { self.items = items + self.direction = direction self.appear = appear } @@ -16,6 +23,9 @@ public final class List: CombinedComponent { if lhs.items != rhs.items { return false } + if lhs.direction != rhs.direction { + return false + } return true } @@ -35,11 +45,19 @@ public final class List: CombinedComponent { var nextOrigin: CGFloat = 0.0 for child in updatedChildren { + let position: CGPoint + switch context.component.direction { + case .horizontal: + position = CGPoint(x: nextOrigin + child.size.width / 2.0, y: child.size.height / 2.0) + nextOrigin += child.size.width + case .vertical: + position = CGPoint(x: child.size.width / 2.0, y: nextOrigin + child.size.height / 2.0) + nextOrigin += child.size.height + } context.add(child - .position(CGPoint(x: child.size.width / 2.0, y: nextOrigin + child.size.height / 2.0)) + .position(position) .appear(context.component.appear) ) - nextOrigin += child.size.height } return context.availableSize diff --git a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift index 22e64ba3a1..3c4698a38e 100644 --- a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift +++ b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift @@ -71,9 +71,7 @@ public final class ComponentHostView: UIView { } let isEnvironmentUpdated = context.erasedEnvironment.calculateIsUpdated() - if isEnvironmentUpdated { - context.erasedEnvironment._isUpdated = false - } + if !forceUpdate, !isEnvironmentUpdated, let currentComponent = self.currentComponent, let currentContainerSize = self.currentContainerSize, let currentSize = self.currentSize { if currentContainerSize == containerSize && currentComponent == component { @@ -98,6 +96,10 @@ public final class ComponentHostView: UIView { transition.setFrame(view: componentView, frame: CGRect(origin: CGPoint(), size: updatedSize)) } + if isEnvironmentUpdated { + context.erasedEnvironment._isUpdated = false + } + self.isUpdating = false return updatedSize diff --git a/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift index 13137aa79d..0706a81724 100644 --- a/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift +++ b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift @@ -116,6 +116,7 @@ public final class SolidRoundedButtonComponent: Component { } if let button = self.button { + button.title = component.title button.updateTheme(component.theme) let height = button.updateLayout(width: availableSize.width, transition: .immediate) transition.setFrame(view: button, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height)), completion: nil) diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index ffb2f11e73..97bb0837db 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -2437,6 +2437,10 @@ public final class ContextController: ViewController, StandalonePresentableContr self.dismiss(result: .default, completion: completion) } + public func dismissWithoutContent() { + self.dismiss(result: .dismissWithoutContent, completion: nil) + } + public func dismissNow() { self.presentingViewController?.dismiss(animated: false, completion: nil) self.dismissed?() diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index f326830dc6..a00809bbd9 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -27,6 +27,7 @@ public final class InAppPurchaseManager: NSObject { public enum PurchaseError { case generic + case cancelled } private final class PaymentTransactionContext { diff --git a/submodules/ItemListStickerPackItem/Sources/ItemListStickerPackItem.swift b/submodules/ItemListStickerPackItem/Sources/ItemListStickerPackItem.swift index f239cf8fa2..e2ae0aa42d 100644 --- a/submodules/ItemListStickerPackItem/Sources/ItemListStickerPackItem.swift +++ b/submodules/ItemListStickerPackItem/Sources/ItemListStickerPackItem.swift @@ -667,12 +667,12 @@ class ItemListStickerPackItemNode: ItemListRevealOptionsItemNode { strongSelf.installationActionBackgroundNode.image = backgroundImage } - let installationActionFrame = CGRect(origin: CGPoint(x: params.width - rightInset - installWidth - 16.0, y: 0.0), size: CGSize(width: 50.0, height: layout.contentSize.height)) + let installationActionFrame = CGRect(origin: CGPoint(x: params.width - rightInset - installWidth - 16.0, y: 0.0), size: CGSize(width: installWidth, height: layout.contentSize.height)) strongSelf.installationActionNode.frame = installationActionFrame let buttonFrame = CGRect(origin: CGPoint(x: params.width - rightInset - installWidth - 16.0, y: installationActionFrame.minY + floor((installationActionFrame.size.height - 28.0) / 2.0)), size: CGSize(width: installWidth, height: 28.0)) strongSelf.installationActionBackgroundNode.frame = buttonFrame - strongSelf.installTextNode.frame = CGRect(origin: CGPoint(x: buttonFrame.minX + floor((buttonFrame.width - installLayout.size.width) / 2.0), y: buttonFrame.minY + floor((buttonFrame.height - installLayout.size.height) / 2.0) + 1.0), size: installLayout.size) + strongSelf.installTextNode.frame = CGRect(origin: CGPoint(x: buttonFrame.minX + floorToScreenPixels((buttonFrame.width - installLayout.size.width) / 2.0), y: buttonFrame.minY + floorToScreenPixels((buttonFrame.height - installLayout.size.height) / 2.0) + 1.0), size: installLayout.size) case .selection: strongSelf.installationActionNode.isHidden = true strongSelf.installationActionBackgroundNode.isHidden = true diff --git a/submodules/PeerInfoUI/Sources/IncreaseLimitHeaderItem.swift b/submodules/PeerInfoUI/Sources/IncreaseLimitHeaderItem.swift index 721af4b8b4..97ef96afc5 100644 --- a/submodules/PeerInfoUI/Sources/IncreaseLimitHeaderItem.swift +++ b/submodules/PeerInfoUI/Sources/IncreaseLimitHeaderItem.swift @@ -145,7 +145,7 @@ class IncreaseLimitHeaderItemNode: ListViewItemNode { let size = strongSelf.hostView.update( transition: .immediate, component: AnyComponent(PremiumLimitDisplayComponent( - inactiveColor: UIColor(rgb: 0xe3e3e9), + inactiveColor: item.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5), activeColors: [ UIColor(rgb: 0x0077ff), UIColor(rgb: 0x6b93ff), diff --git a/submodules/PremiumUI/Resources/star b/submodules/PremiumUI/Resources/star index 16390db8f1..7b4f75d013 100644 Binary files a/submodules/PremiumUI/Resources/star and b/submodules/PremiumUI/Resources/star differ diff --git a/submodules/PremiumUI/Sources/DemoComponent.swift b/submodules/PremiumUI/Sources/DemoComponent.swift new file mode 100644 index 0000000000..8235b3d9a0 --- /dev/null +++ b/submodules/PremiumUI/Sources/DemoComponent.swift @@ -0,0 +1,44 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import ComponentFlow +import AccountContext + +final class DemoComponent: Component { + public typealias EnvironmentType = DemoPageEnvironment + + let context: AccountContext + + public init( + context: AccountContext + ) { + self.context = context + } + + public static func ==(lhs: DemoComponent, rhs: DemoComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + public final class View: UIView { + private var component: DemoComponent? + + public func update(component: DemoComponent, availableSize: CGSize, transition: Transition) -> CGSize { + self.component = component + + return availableSize + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift new file mode 100644 index 0000000000..eaec2901f2 --- /dev/null +++ b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift @@ -0,0 +1,898 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import AccountContext +import TelegramPresentationData +import PresentationDataUtils +import ComponentFlow +import ViewControllerComponent +import SheetComponent +import MultilineTextComponent +import BundleIconComponent +import SolidRoundedButtonComponent +import Markdown + +private final class GradientBackgroundComponent: Component { + public let colors: [UIColor] + + public init( + colors: [UIColor] + ) { + self.colors = colors + } + + public static func ==(lhs: GradientBackgroundComponent, rhs: GradientBackgroundComponent) -> Bool { + if lhs.colors != rhs.colors { + return false + } + return true + } + + public final class View: UIView { + private let clipLayer: CALayer + private let gradientLayer: CAGradientLayer + + private var component: GradientBackgroundComponent? + + override init(frame: CGRect) { + self.clipLayer = CALayer() + self.clipLayer.cornerRadius = 10.0 + self.clipLayer.masksToBounds = true + + self.gradientLayer = CAGradientLayer() + + super.init(frame: frame) + + self.layer.addSublayer(self.clipLayer) + self.clipLayer.addSublayer(gradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + func update(component: GradientBackgroundComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.clipLayer.frame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: availableSize.height + 10.0)) + self.gradientLayer.frame = CGRect(origin: .zero, size: availableSize) + + var locations: [NSNumber] = [] + let delta = 1.0 / CGFloat(component.colors.count - 1) + for i in 0 ..< component.colors.count { + locations.append((delta * CGFloat(i)) as NSNumber) + } + self.gradientLayer.locations = locations + self.gradientLayer.colors = component.colors.reversed().map { $0.cgColor } + self.gradientLayer.type = .radial + self.gradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.gradientLayer.endPoint = CGPoint(x: -2.0, y: 3.0) + + self.component = component + + self.setupGradientAnimations() + + return availableSize + } + + private func setupGradientAnimations() { + if let _ = self.gradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.gradientLayer.endPoint + let value: CGFloat + if previousValue.x < -0.5 { + value = 0.5 + } else { + value = 2.0 + } + let newValue = CGPoint(x: -value, y: 1.0 + value) +// let secondNewValue = CGPoint(x: 3.0 - value, y: -2.0 + value) + self.gradientLayer.endPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "endPoint") + animation.duration = 4.5 + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + self?.setupGradientAnimations() + } + + self.gradientLayer.add(animation, forKey: "movement") + +// let secondPreviousValue = self.gradientLayer.startPoint +// let secondAnimation = CABasicAnimation(keyPath: "startPoint") +// secondAnimation.duration = 4.5 +// secondAnimation.fromValue = secondPreviousValue +// secondAnimation.toValue = secondNewValue +// +// self.gradientLayer.add(secondAnimation, forKey: "movement2") + + CATransaction.commit() + } + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class DemoPageEnvironment: Equatable { + public let isDisplaying: Bool + public let isCentral: Bool + + public init(isDisplaying: Bool, isCentral: Bool) { + self.isDisplaying = isDisplaying + self.isCentral = isCentral + } + + public static func ==(lhs: DemoPageEnvironment, rhs: DemoPageEnvironment) -> Bool { + if lhs.isDisplaying != rhs.isDisplaying { + return false + } + if lhs.isCentral != rhs.isCentral { + return false + } + return true + } +} + +private final class PageComponent: CombinedComponent { + typealias EnvironmentType = ChildEnvironment + + private let content: AnyComponent + private let title: String + private let text: String + private let textColor: UIColor + + init( + content: AnyComponent, + title: String, + text: String, + textColor: UIColor + ) { + self.content = content + self.title = title + self.text = text + self.textColor = textColor + } + + static func ==(lhs: PageComponent, rhs: PageComponent) -> Bool { + if lhs.content != rhs.content { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.text != rhs.text { + return false + } + if lhs.textColor != rhs.textColor { + return false + } + return true + } + + static var body: Body { + let children = ChildMap(environment: ChildEnvironment.self, keyedBy: AnyHashable.self) + let title = Child(MultilineTextComponent.self) + let text = Child(MultilineTextComponent.self) + + return { context in + let availableSize = context.availableSize + let component = context.component + + let sideInset: CGFloat = 16.0 //+ environment.safeInsets.left + let textSideInset: CGFloat = 24.0 //+ environment.safeInsets.left + + let textColor = component.textColor + let textFont = Font.regular(17.0) + let boldTextFont = Font.semibold(17.0) + + let content = children["main"].update( + component: component.content, + environment: { + context.environment[ChildEnvironment.self] + }, + availableSize: CGSize(width: availableSize.width, height: availableSize.width), + transition: context.transition + ) + + let title = title.update( + component: MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.title, + font: boldTextFont, + textColor: component.textColor, + paragraphAlignment: .center + )), + horizontalAlignment: .center, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude), + transition: .immediate + ) + + let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: textColor), linkAttribute: { _ in + return nil + }) + let text = text.update( + component: MultilineTextComponent( + text: .markdown(text: component.text, attributes: markdownAttributes), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.0 + ), + availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height), + transition: .immediate + ) + + context.add(title + .position(CGPoint(x: context.availableSize.width / 2.0, y: content.size.height + 40.0)) + ) + context.add(text + .position(CGPoint(x: context.availableSize.width / 2.0, y: content.size.height + 80.0)) + ) + context.add(content + .position(CGPoint(x: content.size.width / 2.0, y: content.size.height / 2.0)) + ) + + return availableSize + } + } +} + +private final class DemoPagerComponent: Component { + public final class Item: Equatable { + public let content: AnyComponentWithIdentity + + public init(_ content: AnyComponentWithIdentity) { + self.content = content + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.content != rhs.content { + return false + } + + return true + } + } + + public let items: [Item] + public let index: Int + + public init( + items: [Item], + index: Int = 0 + ) { + self.items = items + self.index = index + } + + public static func ==(lhs: DemoPagerComponent, rhs: DemoPagerComponent) -> Bool { + if lhs.items != rhs.items { + return false + } + return true + } + + public final class View: UIView, UIScrollViewDelegate { + private let scrollView: UIScrollView + private var itemViews: [AnyHashable: ComponentHostView] = [:] + + private var component: DemoPagerComponent? + + override init(frame: CGRect) { + self.scrollView = UIScrollView(frame: frame) + self.scrollView.isPagingEnabled = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.bounces = false + self.scrollView.layer.cornerRadius = 10.0 + + super.init(frame: frame) + + self.scrollView.delegate = self + + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let component = self.component else { + return + } + + for item in component.items { + if let itemView = self.itemViews[item.content.id] { + let isDisplaying = itemView.frame.intersects(self.scrollView.bounds) + + let environment = DemoPageEnvironment(isDisplaying: isDisplaying, isCentral: isDisplaying) + let _ = itemView.update( + transition: .immediate, + component: item.content.component, + environment: { environment }, + containerSize: self.bounds.size + ) + } + } + } + + func update(component: DemoPagerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + var validIds: [AnyHashable] = [] + + let firstTime = self.itemViews.isEmpty + + let contentSize = CGSize(width: availableSize.width * CGFloat(component.items.count), height: availableSize.height) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + self.scrollView.frame = CGRect(origin: .zero, size: availableSize) + + if firstTime { + self.scrollView.contentOffset = CGPoint(x: CGFloat(component.index) * availableSize.width, y: 0.0) + } + + var i = 0 + for item in component.items { + validIds.append(item.content.id) + + let itemView: ComponentHostView + var itemTransition = transition + + if let current = self.itemViews[item.content.id] { + itemView = current + } else { + itemTransition = transition.withAnimation(.none) + itemView = ComponentHostView() + self.itemViews[item.content.id] = itemView + self.scrollView.addSubview(itemView) + } + + let itemFrame = CGRect(origin: CGPoint(x: availableSize.width * CGFloat(i), y: 0.0), size: availableSize) + let isDisplaying = itemFrame.intersects(self.scrollView.bounds) + + let environment = DemoPageEnvironment(isDisplaying: isDisplaying, isCentral: isDisplaying) + let _ = itemView.update( + transition: itemTransition, + component: item.content.component, + environment: { environment }, + containerSize: availableSize + ) + + itemView.frame = itemFrame + + i += 1 + } + + var removeIds: [AnyHashable] = [] + for (id, itemView) in self.itemViews { + if !validIds.contains(id) { + removeIds.append(id) + itemView.removeFromSuperview() + } + } + for id in removeIds { + self.itemViews.removeValue(forKey: id) + } + + self.component = component + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +private final class DemoSheetContent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let subject: PremiumDemoScreen.Subject + let source: PremiumDemoScreen.Source + let action: () -> Void + let dismiss: () -> Void + + init(context: AccountContext, subject: PremiumDemoScreen.Subject, source: PremiumDemoScreen.Source, action: @escaping () -> Void, dismiss: @escaping () -> Void) { + self.context = context + self.subject = subject + self.source = source + self.action = action + self.dismiss = dismiss + } + + static func ==(lhs: DemoSheetContent, rhs: DemoSheetContent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.subject != rhs.subject { + return false + } + if lhs.source != rhs.source { + return false + } + return true + } + + final class State: ComponentState { + private let context: AccountContext + var cachedCloseImage: UIImage? + + var reactions: [AvailableReactions.Reaction]? + var stickers: [TelegramMediaFile]? + var reactionsDisposable: Disposable? + var stickersDisposable: Disposable? + + init(context: AccountContext) { + self.context = context + + super.init() + + self.reactionsDisposable = (self.context.engine.stickers.availableReactions() + |> map { reactions -> [AvailableReactions.Reaction] in + if let reactions = reactions { + return reactions.reactions.filter { $0.isPremium } + } else { + return [] + } + } + |> deliverOnMainQueue).start(next: { [weak self] reactions in + guard let strongSelf = self else { + return + } + strongSelf.reactions = reactions + strongSelf.updated(transition: .immediate) + }) + + self.stickersDisposable = (self.context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [Namespaces.OrderedItemList.PremiumStickers], namespaces: [Namespaces.ItemCollection.CloudDice], aroundIndex: nil, count: 100) + |> map { view -> [TelegramMediaFile] in + var result: [TelegramMediaFile] = [] + if let premiumStickers = view.orderedItemListsViews.first { + for i in 0 ..< premiumStickers.items.count { + if let item = premiumStickers.items[i].contents.get(RecentMediaItem.self) { + result.append(item.media) + } + } + } + return result + } + |> deliverOnMainQueue).start(next: { [weak self] stickers in + guard let strongSelf = self else { + return + } + strongSelf.stickers = stickers + strongSelf.updated(transition: .immediate) + }) + } + + deinit { + self.reactionsDisposable?.dispose() + self.stickersDisposable?.dispose() + } + } + + func makeState() -> State { + return State(context: self.context) + } + + static var body: Body { + let closeButton = Child(Button.self) + let background = Child(GradientBackgroundComponent.self) + let pager = Child(DemoPagerComponent.self) + let button = Child(SolidRoundedButtonComponent.self) + let dots = Child(BundleIconComponent.self) + + return { context in + let environment = context.environment[ViewControllerComponentContainer.Environment.self].value + let component = context.component + let theme = environment.theme + let strings = environment.strings + + let state = context.state + + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + + let background = background.update( + component: GradientBackgroundComponent(colors: [ + UIColor(rgb: 0x0077ff), + UIColor(rgb: 0x6b93ff), + UIColor(rgb: 0x8878ff), + UIColor(rgb: 0xe46ace) + ]), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.width), + transition: .immediate + ) + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) + ) + + let closeImage: UIImage + if let image = state.cachedCloseImage { + closeImage = image + } else { + closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0xffffff, alpha: 0.1), foregroundColor: UIColor(rgb: 0xffffff))! + state.cachedCloseImage = closeImage + } + + if let reactions = state.reactions, let stickers = state.stickers { + let textColor = theme.actionSheet.primaryTextColor + + let items: [DemoPagerComponent.Item] = [ + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.moreUpload, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_UploadSize, + text: strings.Premium_UploadSizeInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.fasterDownload, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_FasterSpeed, + text: strings.Premium_FasterSpeedInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.voiceToText, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_VoiceToText, + text: strings.Premium_VoiceToTextInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.noAds, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_NoAds, + text: strings.Premium_NoAdsInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.uniqueReactions, + component: AnyComponent( + PageComponent( + content: AnyComponent( + ReactionsCarouselComponent( + context: component.context, + theme: environment.theme, + reactions: reactions + ) + ), + title: strings.Premium_Reactions, + text: strings.Premium_ReactionsInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.premiumStickers, + component: AnyComponent( + PageComponent( + content: AnyComponent( + StickersCarouselComponent( + context: component.context, + stickers: stickers + ) + ), + title: strings.Premium_Stickers, + text: strings.Premium_StickersInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.advancedChatManagement, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_ChatManagement, + text: strings.Premium_ChatManagementInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.profileBadge, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_Badge, + text: strings.Premium_BadgeInfo, + textColor: textColor + ) + ) + ) + ), + DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.animatedUserpics, + component: AnyComponent( + PageComponent( + content: AnyComponent(DemoComponent( + context: component.context + )), + title: strings.Premium_Avatar, + text: strings.Premium_AvatarInfo, + textColor: textColor + ) + ) + ) + ) + ] + let index = items.firstIndex(where: { (component.subject as AnyHashable) == $0.content.id }) ?? 0 + + let pager = pager.update( + component: DemoPagerComponent( + items: items, + index: index + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.width + 154.0), + transition: .immediate + ) + context.add(pager + .position(CGPoint(x: context.availableSize.width / 2.0, y: pager.size.height / 2.0)) + ) + } + + let closeButton = closeButton.update( + component: Button( + content: AnyComponent(Image(image: closeImage)), + action: { [weak component] in + component?.dismiss() + } + ), + availableSize: CGSize(width: 30.0, height: 30.0), + transition: .immediate + ) + context.add(closeButton + .position(CGPoint(x: context.availableSize.width - environment.safeInsets.left - closeButton.size.width, y: 28.0)) + ) + + let buttonText: String + switch component.source { + case let .intro(price): + buttonText = strings.Premium_SubscribeFor(price ?? "–").string + case .other: + buttonText = strings.Premium_MoreAboutPremium + } + + let button = button.update( + component: SolidRoundedButtonComponent( + title: buttonText, + theme: SolidRoundedButtonComponent.Theme( + backgroundColor: .black, + backgroundColors: [ + UIColor(rgb: 0x0077ff), + UIColor(rgb: 0x6b93ff), + UIColor(rgb: 0x8878ff), + UIColor(rgb: 0xe46ace) + ], + foregroundColor: .white + ), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 10.0, + gloss: true, + iconPosition: .right, + action: { [weak component] in + guard let component = component else { + return + } + component.dismiss() + component.action() + } + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + + let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: context.availableSize.width + 154.0 + 20.0), size: button.size) + context.add(button + .position(CGPoint(x: buttonFrame.midX, y: buttonFrame.midY)) + ) + + let dots = dots.update( + component: BundleIconComponent(name: "Components/Dots", tintColor: nil), + availableSize: CGSize(width: 110.0, height: 20.0), + transition: .immediate + ) + context.add(dots + .position(CGPoint(x: context.availableSize.width / 2.0, y: buttonFrame.minY - dots.size.height - 18.0)) + ) + + let contentSize = CGSize(width: context.availableSize.width, height: buttonFrame.maxY + 5.0 + environment.safeInsets.bottom) + + return contentSize + } + } +} + + +private final class DemoSheetComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let subject: PremiumDemoScreen.Subject + let source: PremiumDemoScreen.Source + let action: () -> Void + + init(context: AccountContext, subject: PremiumDemoScreen.Subject, source: PremiumDemoScreen.Source, action: @escaping () -> Void) { + self.context = context + self.subject = subject + self.source = source + self.action = action + } + + static func ==(lhs: DemoSheetComponent, rhs: DemoSheetComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.subject != rhs.subject { + return false + } + if lhs.source != rhs.source { + return false + } + + return true + } + + static var body: Body { + let sheet = Child(SheetComponent.self) + let animateOut = StoredActionSlot(Action.self) + + return { context in + let environment = context.environment[EnvironmentType.self] + + let controller = environment.controller + + let sheet = sheet.update( + component: SheetComponent( + content: AnyComponent(DemoSheetContent( + context: context.component.context, + subject: context.component.subject, + source: context.component.source, + action: context.component.action, + dismiss: { + animateOut.invoke(Action { _ in + if let controller = controller() { + controller.dismiss(completion: nil) + } + }) + } + )), + backgroundColor: environment.theme.actionSheet.opaqueItemBackgroundColor, + animateOut: animateOut + ), + environment: { + environment + SheetComponentEnvironment( + isDisplaying: environment.value.isVisible, + dismiss: { + animateOut.invoke(Action { _ in + if let controller = controller() { + controller.dismiss(completion: nil) + } + }) + } + ) + }, + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + return context.availableSize + } + } +} + +public class PremiumDemoScreen: ViewControllerComponentContainer { + public enum Subject { + case moreUpload + case fasterDownload + case voiceToText + case noAds + case uniqueReactions + case premiumStickers + case advancedChatManagement + case profileBadge + case animatedUserpics + } + + public enum Source: Equatable { + case intro(String?) + case other + } + + var disposed: () -> Void = {} + + public init(context: AccountContext, subject: PremiumDemoScreen.Subject, source: PremiumDemoScreen.Source = .other, action: @escaping () -> Void) { + super.init(context: context, component: DemoSheetComponent(context: context, subject: subject, source: source, action: action), navigationBarAppearance: .none) + + self.navigationPresentation = .flatModal + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposed() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + self.view.disablesInteractiveModalDismiss = true + } +} + diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index 928d69fc12..1be2281a79 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -19,7 +19,7 @@ import ConfettiEffect import TextFormat import InstantPageCache -public enum PremiumSource { +public enum PremiumSource: Equatable { case settings case stickers case reactions @@ -33,6 +33,7 @@ public enum PremiumSource { case folders case chatsPerFolder case accounts + case deeplink(String?) var identifier: String { switch self { @@ -62,6 +63,12 @@ public enum PremiumSource { return "double_limits__dialog_filters_chats" case .accounts: return "double_limits__accounts" + case let .deeplink(reference): + if let reference = reference { + return "deeplink_\(reference)" + } else { + return "deeplink" + } } } } @@ -718,26 +725,49 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment) let context: AccountContext + let source: PremiumSource + let isPremium: Bool? + let price: String? + let present: (ViewController) -> Void + let buy: () -> Void + let updateIsFocused: (Bool) -> Void - init(context: AccountContext) { + init(context: AccountContext, source: PremiumSource, isPremium: Bool?, price: String?, present: @escaping (ViewController) -> Void, buy: @escaping () -> Void, updateIsFocused: @escaping (Bool) -> Void) { self.context = context + self.source = source + self.isPremium = isPremium + self.price = price + self.present = present + self.buy = buy + self.updateIsFocused = updateIsFocused } static func ==(lhs: PremiumIntroScreenContentComponent, rhs: PremiumIntroScreenContentComponent) -> Bool { if lhs.context !== rhs.context { return false } + if lhs.source != rhs.source { + return false + } + if lhs.isPremium != rhs.isPremium { + return false + } + if lhs.price != rhs.price { + return false + } return true } final class State: ComponentState { private let context: AccountContext + + var price: String? private var disposable: Disposable? - var configuration = PremiumIntroConfiguration.defaultValue + private(set) var configuration = PremiumIntroConfiguration.defaultValue - init(context: AccountContext) { + init(context: AccountContext, source: PremiumSource) { self.context = context super.init() @@ -752,6 +782,25 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { if let strongSelf = self { strongSelf.configuration = PremiumIntroConfiguration.with(appConfiguration: appConfiguration) strongSelf.updated(transition: .immediate) + + var jsonString: String = "{" + jsonString += "\"source\": \"\(source.identifier)\"," + + jsonString += "\"data\": {\"premium_promo_order\":[" + var isFirst = true + for perk in strongSelf.configuration.perks { + if !isFirst { + jsonString += "," + } + isFirst = false + jsonString += "\"\(perk.identifier)\"" + } + jsonString += "]}}" + + + if let data = jsonString.data(using: .utf8), let json = JSON(data: data) { + addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_show", data: json) + } } }) } @@ -762,7 +811,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { } func makeState() -> State { - return State(context: self.context) + return State(context: self.context, source: self.source) } static var body: Body { @@ -781,6 +830,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let scrollEnvironment = context.environment[ScrollChildEnvironment.self].value let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let state = context.state + state.price = context.component.price let theme = environment.theme let strings = environment.strings @@ -830,7 +880,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let text = text.update( component: MultilineTextComponent( text: .markdown( - text: strings.Premium_Description, + text: context.component.isPremium == true ? strings.Premium_SubscribedDescription : strings.Premium_Description, attributes: markdownAttributes ), horizontalAlignment: .center, @@ -862,6 +912,11 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { var items: [SectionGroupComponent.Item] = [] + let accountContext = context.component.context + let present = context.component.present + let buy = context.component.buy + let updateIsFocused = context.component.updateIsFocused + var i = 0 for perk in state.configuration.perks { let iconBackgroundColors = gradientColors[i] @@ -883,8 +938,51 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { ) ) ), - action: { + action: { [weak state] in + var demoSubject: PremiumDemoScreen.Subject + switch perk { + case .doubleLimits: + return + case .moreUpload: + demoSubject = .moreUpload + case .fasterDownload: + demoSubject = .fasterDownload + case .voiceToText: + demoSubject = .voiceToText + case .noAds: + demoSubject = .noAds + case .uniqueReactions: + demoSubject = .uniqueReactions + case .premiumStickers: + demoSubject = .premiumStickers + case .advancedChatManagement: + demoSubject = .advancedChatManagement + case .profileBadge: + demoSubject = .profileBadge + case .animatedUserpics: + demoSubject = .animatedUserpics + } + var dismissImpl: (() -> Void)? + let controller = PremiumDemoScreen( + context: accountContext, + subject: demoSubject, + source: .intro(state?.price), + action: { + dismissImpl?() + buy() + } + ) + controller.disposed = { + updateIsFocused(false) + } + present(controller) + dismissImpl = { [weak controller] in + controller?.dismiss(animated: true, completion: nil) + } + updateIsFocused(true) + + addAppLogEvent(postbox: accountContext.account.postbox, type: "premium.promo_screen_tap", data: ["item": perk.identifier]) } )) i += 1 @@ -901,241 +999,6 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude), transition: context.transition ) - -// - -// let section = section.update( -// component: SectionGroupComponent( -// items: [ -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "limits", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Limits", -// iconBackgroundColors: [ -// UIColor(rgb: 0xF28528), -// UIColor(rgb: 0xEF7633) -// ], -// title: strings.Premium_DoubledLimits, -// titleColor: titleColor, -// subtitle: strings.Premium_DoubledLimitsInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "upload", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Upload", -// iconBackgroundColors: [ -// UIColor(rgb: 0xEA5F43), -// UIColor(rgb: 0xE7504E) -// ], -// title: strings.Premium_UploadSize, -// titleColor: titleColor, -// subtitle: strings.Premium_UploadSizeInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "speed", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Speed", -// iconBackgroundColors: [ -// UIColor(rgb: 0xDE4768), -// UIColor(rgb: 0xD54D82) -// ], -// title: strings.Premium_FasterSpeed, -// titleColor: titleColor, -// subtitle: strings.Premium_FasterSpeedInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "voice", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Voice", -// iconBackgroundColors: [ -// UIColor(rgb: 0xDE4768), -// UIColor(rgb: 0xD54D82) -// ], -// title: strings.Premium_VoiceToText, -// titleColor: titleColor, -// subtitle: strings.Premium_VoiceToTextInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "noAds", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/NoAds", -// iconBackgroundColors: [ -// UIColor(rgb: 0xC654A8), -// UIColor(rgb: 0xBE5AC2) -// ], -// title: strings.Premium_NoAds, -// titleColor: titleColor, -// subtitle: strings.Premium_NoAdsInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "reactions", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Reactions", -// iconBackgroundColors: [ -// UIColor(rgb: 0xAF62E9), -// UIColor(rgb: 0xA668FF) -// ], -// title: strings.Premium_Reactions, -// titleColor: titleColor, -// subtitle: strings.Premium_ReactionsInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "stickers", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Stickers", -// iconBackgroundColors: [ -// UIColor(rgb: 0x9674FF), -// UIColor(rgb: 0x8C7DFF) -// ], -// title: strings.Premium_Stickers, -// titleColor: titleColor, -// subtitle: strings.Premium_StickersInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "chat", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Chat", -// iconBackgroundColors: [ -// UIColor(rgb: 0x9674FF), -// UIColor(rgb: 0x8C7DFF) -// ], -// title: strings.Premium_ChatManagement, -// titleColor: titleColor, -// subtitle: strings.Premium_ChatManagementInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "badge", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Badge", -// iconBackgroundColors: [ -// UIColor(rgb: 0x7B88FF), -// UIColor(rgb: 0x7091FF) -// ], -// title: strings.Premium_Badge, -// titleColor: titleColor, -// subtitle: strings.Premium_BadgeInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// SectionGroupComponent.Item( -// AnyComponentWithIdentity( -// id: "avatar", -// component: AnyComponent( -// PerkComponent( -// iconName: "Premium/Perk/Avatar", -// iconBackgroundColors: [ -// UIColor(rgb: 0x609DFF), -// UIColor(rgb: 0x56A5FF) -// ], -// title: strings.Premium_Avatar, -// titleColor: titleColor, -// subtitle: strings.Premium_AvatarInfo, -// subtitleColor: subtitleColor, -// arrowColor: arrowColor -// ) -// ) -// ), -// action: { -// -// } -// ), -// ], -// backgroundColor: environment.theme.list.itemBlocksBackgroundColor, -// selectionColor: environment.theme.list.itemHighlightedBackgroundColor, -// separatorColor: environment.theme.list.itemBlocksSeparatorColor -// ), -// environment: {}, -// availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude), -// transition: context.transition -// ) context.add(section .position(CGPoint(x: availableWidth / 2.0, y: size.height + section.size.height / 2.0)) .clipsToBounds(true) @@ -1314,12 +1177,16 @@ private final class PremiumIntroScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext + let source: PremiumSource let updateInProgress: (Bool) -> Void + let present: (ViewController) -> Void let completion: () -> Void - init(context: AccountContext, updateInProgress: @escaping (Bool) -> Void, completion: @escaping () -> Void) { + init(context: AccountContext, source: PremiumSource, updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, completion: @escaping () -> Void) { self.context = context + self.source = source self.updateInProgress = updateInProgress + self.present = present self.completion = completion } @@ -1327,6 +1194,9 @@ private final class PremiumIntroScreenComponent: CombinedComponent { if lhs.context !== rhs.context { return false } + if lhs.source != rhs.source { + return false + } return true } @@ -1338,8 +1208,12 @@ private final class PremiumIntroScreenComponent: CombinedComponent { var topContentOffset: CGFloat? var bottomContentOffset: CGFloat? + var hasIdleAnimations = true + var inProgress = false var premiumProduct: InAppPurchaseManager.Product? + var isPremium: Bool? + private var disposable: Disposable? private var paymentDisposable = MetaDisposable() private var activationDisposable = MetaDisposable() @@ -1352,10 +1226,17 @@ private final class PremiumIntroScreenComponent: CombinedComponent { super.init() if let inAppPurchaseManager = context.sharedContext.inAppPurchaseManager { - self.disposable = (inAppPurchaseManager.availableProducts - |> deliverOnMainQueue).start(next: { [weak self] products in + self.disposable = combineLatest( + queue: Queue.mainQueue(), + inAppPurchaseManager.availableProducts, + context.account.postbox.peerView(id: context.account.peerId) + |> map { view -> Bool in + return view.peers[view.peerId]?.isPremium ?? false + } + ).start(next: { [weak self] products, isPremium in if let strongSelf = self { strongSelf.premiumProduct = products.first + strongSelf.isPremium = isPremium strongSelf.updated(transition: .immediate) } }) @@ -1374,6 +1255,8 @@ private final class PremiumIntroScreenComponent: CombinedComponent { return } + addAppLogEvent(postbox: self.context.account.postbox, type: "premium.promo_screen_accept") + self.inProgress = true self.updateInProgress(true) self.updated(transition: .immediate) @@ -1390,14 +1273,26 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } })) } - }, error: { [weak self] _ in + }, error: { [weak self] error in if let strongSelf = self { strongSelf.inProgress = false strongSelf.updateInProgress(false) strongSelf.updated(transition: .immediate) + + switch error { + case .generic: + addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_fail") + case .cancelled: + break + } } })) } + + func updateIsFocused(_ isFocused: Bool) { + self.hasIdleAnimations = !isFocused + self.updated(transition: .immediate) + } } func makeState() -> State { @@ -1427,7 +1322,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { } let star = star.update( - component: PremiumStarComponent(isVisible: starIsVisible), + component: PremiumStarComponent(isVisible: starIsVisible, hasIdleAnimations: state.hasIdleAnimations), availableSize: CGSize(width: min(390.0, context.availableSize.width), height: 220.0), transition: context.transition ) @@ -1450,7 +1345,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { let title = title.update( component: Text( - text: environment.strings.Premium_Title, + text: state.isPremium == true ? environment.strings.Premium_SubscribedTitle : environment.strings.Premium_Title, font: Font.bold(28.0), color: environment.theme.rootController.navigationBar.primaryTextColor ), @@ -1504,7 +1399,16 @@ private final class PremiumIntroScreenComponent: CombinedComponent { let scrollContent = scrollContent.update( component: ScrollComponent( content: AnyComponent(PremiumIntroScreenContentComponent( - context: context.component.context + context: context.component.context, + source: context.component.source, + isPremium: state.isPremium, + price: state.premiumProduct?.price, + present: context.component.present, + buy: { [weak state] in + state?.buy() + }, updateIsFocused: { [weak state] isFocused in + state?.updateIsFocused(isFocused) + } )), contentInsets: UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: bottomPanel.size.height, right: 0.0), contentOffsetUpdated: { [weak state] topContentOffset, bottomContentOffset in @@ -1606,16 +1510,21 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { return self._ready } - public init(context: AccountContext, modal: Bool = true, reference: String? = nil, source: PremiumSource? = nil) { + public init(context: AccountContext, modal: Bool = true, source: PremiumSource) { self.context = context var updateInProgressImpl: ((Bool) -> Void)? + var presentImpl: ((ViewController) -> Void)? var completionImpl: (() -> Void)? super.init(context: context, component: PremiumIntroScreenComponent( context: context, + source: source, updateInProgress: { inProgress in updateInProgressImpl?(inProgress) }, + present: { c in + presentImpl?(c) + }, completion: { completionImpl?() } @@ -1639,6 +1548,10 @@ public final class PremiumIntroScreen: ViewControllerComponentContainer { } } + presentImpl = { [weak self] c in + self?.push(c) + } + completionImpl = { [weak self] in if let strongSelf = self { strongSelf.view.addSubview(ConfettiView(frame: strongSelf.view.bounds)) diff --git a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift index fdb6bea25e..c660b7e8a9 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift @@ -16,16 +16,16 @@ import BundleIconComponent import SolidRoundedButtonComponent import Markdown -private func generateCloseButtonImage(theme: PresentationTheme) -> UIImage? { +func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(UIColor(rgb: 0x808084, alpha: 0.1).cgColor) + context.setFillColor(backgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setLineWidth(2.0) context.setLineCap(.round) - context.setStrokeColor(theme.actionSheet.inputClearButtonColor.cgColor) + context.setStrokeColor(foregroundColor.cgColor) context.move(to: CGPoint(x: 10.0, y: 10.0)) context.addLine(to: CGPoint(x: 20.0, y: 20.0)) @@ -113,6 +113,7 @@ private class PremiumLimitAnimationComponent: Component { self.activeBackground = SimpleLayer() self.badgeView = UIView() + self.badgeView.alpha = 0.0 self.badgeView.layer.anchorPoint = CGPoint(x: 0.5, y: 1.0) self.badgeMaskBackgroundView = UIView() @@ -143,7 +144,7 @@ private class PremiumLimitAnimationComponent: Component { self.badgeCountLabel = RollingLabel() self.badgeCountLabel.font = Font.with(size: 24.0, design: .round, weight: .semibold, traits: []) self.badgeCountLabel.textColor = .white - self.badgeCountLabel.text(num: 0) + self.badgeCountLabel.configure(with: "0") super.init(frame: frame) @@ -203,8 +204,11 @@ private class PremiumLimitAnimationComponent: Component { self.badgeView.layer.add(rotateAnimation, forKey: "appearance2") self.badgeView.layer.add(returnAnimation, forKey: "appearance3") - if let badgeText = component.badgeText, let num = Int(badgeText) { - self.badgeCountLabel.text(num: num) + self.badgeView.alpha = 1.0 + self.badgeView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + + if let badgeText = component.badgeText { + self.badgeCountLabel.configure(with: badgeText) } } @@ -251,7 +255,18 @@ private class PremiumLimitAnimationComponent: Component { self.badgeMaskArrowView.frame = CGRect(origin: CGPoint(x: (badgeSize.width - 44.0) / 2.0, y: badgeSize.height - 12.0), size: CGSize(width: 44.0, height: 12.0)) self.badgeView.bounds = CGRect(origin: .zero, size: badgeSize) - self.badgeView.center = CGPoint(x: 3.0 + (availableSize.width - 6.0) * component.badgePosition, y: 82.0) + if component.badgePosition > 1.0 - .ulpOfOne { + let offset = badgeWidth / 2.0 - 16.0 + self.badgeView.center = CGPoint(x: 3.0 + (availableSize.width - 6.0) * component.badgePosition - offset, y: 82.0) + self.badgeMaskArrowView.frame = self.badgeMaskArrowView.frame.offsetBy(dx: offset - 18.0, dy: 0.0) + } else { + self.badgeView.center = CGPoint(x: 3.0 + (availableSize.width - 6.0) * component.badgePosition, y: 82.0) + + if self.badgeView.frame.maxX > availableSize.width { + let delta = self.badgeView.frame.maxX - availableSize.width - 6.0 + self.badgeView.center = self.badgeView.center.offsetBy(dx: -delta, dy: 0.0) + } + } self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: badgeSize.width * 3.0, height: badgeSize.height)) if self.badgeForeground.animation(forKey: "movement") == nil { self.badgeForeground.position = CGPoint(x: badgeSize.width * 3.0 / 2.0 - self.badgeForeground.frame.width * 0.35, y: badgeSize.height / 2.0) @@ -616,7 +631,7 @@ private final class LimitSheetContent: CombinedComponent { if let (image, theme) = state.cachedCloseImage, theme === environment.theme { closeImage = image } else { - closeImage = generateCloseButtonImage(theme: theme)! + closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)! state.cachedCloseImage = (closeImage, theme) } @@ -634,6 +649,7 @@ private final class LimitSheetContent: CombinedComponent { .position(CGPoint(x: context.availableSize.width - environment.safeInsets.left - closeButton.size.width, y: 28.0)) ) + var titleText = strings.Premium_LimitReached let iconName: String let badgeText: String let string: String @@ -669,20 +685,21 @@ private final class LimitSheetContent: CombinedComponent { premiumValue = "\(premiumLimit)" badgePosition = CGFloat(component.count) / CGFloat(premiumLimit) case .files: - let limit = Int64(state.limits.maxUploadFileParts) * 512 * 1024 - let premiumLimit = Int64(state.limits.maxUploadFileParts) * 512 * 1024 + let limit = Int64(state.limits.maxUploadFileParts) * 512 * 1024 + 1024 * 1024 * 100 + let premiumLimit = Int64(state.premiumLimits.maxUploadFileParts) * 512 * 1024 + 1024 * 1024 * 100 iconName = "Premium/File" badgeText = dataSizeString(limit, formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: environment.dateTimeFormat.decimalSeparator)) string = strings.Premium_MaxFileSizeText(dataSizeString(premiumLimit, formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: environment.dateTimeFormat.decimalSeparator))).string defaultValue = "" premiumValue = dataSizeString(premiumLimit, formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: environment.dateTimeFormat.decimalSeparator)) badgePosition = 0.5 + titleText = strings.Premium_FileTooLarge } let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString( - string: strings.Premium_LimitReached, + string: titleText, font: Font.semibold(17.0), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center diff --git a/submodules/PremiumUI/Sources/PremiumReactionsScreen.swift b/submodules/PremiumUI/Sources/PremiumReactionsScreen.swift deleted file mode 100644 index 79f2703b7d..0000000000 --- a/submodules/PremiumUI/Sources/PremiumReactionsScreen.swift +++ /dev/null @@ -1,272 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import Postbox -import TelegramCore -import SwiftSignalKit -import AccountContext -import TelegramPresentationData -import PresentationDataUtils -import SolidRoundedButtonNode -import AppBundle - -public final class PremiumReactionsScreen: ViewController { - private let context: AccountContext - private var presentationData: PresentationData - private var presentationDataDisposable: Disposable? - private let updatedPresentationData: (initial: PresentationData, signal: Signal)? - private let reactions: [AvailableReactions.Reaction] - - public var proceed: (() -> Void)? - - private class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { - private weak var controller: PremiumReactionsScreen? - private var presentationData: PresentationData - - private let blurView: UIVisualEffectView - private let vibrancyView: UIVisualEffectView - private let dimNode: ASDisplayNode - private let darkDimNode: ASDisplayNode - private let containerNode: ASDisplayNode - - private let textNode: ImmediateTextNode - private let overlayTextNode: ImmediateTextNode - private let proceedButton: SolidRoundedButtonNode - private let cancelButton: HighlightableButtonNode - private let carouselNode: ReactionCarouselNode - - private var validLayout: ContainerViewLayout? - - init(controller: PremiumReactionsScreen) { - self.controller = controller - self.presentationData = controller.presentationData - - self.dimNode = ASDisplayNode() - let blurEffect = UIBlurEffect(style: self.presentationData.theme.overallDarkAppearance ? .dark : .light) - self.blurView = UIVisualEffectView(effect: blurEffect) - self.blurView.isUserInteractionEnabled = false - - self.vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect)) - - self.darkDimNode = ASDisplayNode() - self.darkDimNode.alpha = 0.0 - self.darkDimNode.backgroundColor = self.presentationData.theme.contextMenu.dimColor - self.darkDimNode.isUserInteractionEnabled = false - - self.dimNode.backgroundColor = UIColor(white: self.presentationData.theme.overallDarkAppearance ? 0.0 : 1.0, alpha: 0.5) - - self.containerNode = ASDisplayNode() - - self.textNode = ImmediateTextNode() - self.textNode.displaysAsynchronously = false - self.textNode.textAlignment = .center - self.textNode.maximumNumberOfLines = 0 - self.textNode.lineSpacing = 0.1 - - self.overlayTextNode = ImmediateTextNode() - self.overlayTextNode.displaysAsynchronously = false - self.overlayTextNode.textAlignment = .center - self.overlayTextNode.maximumNumberOfLines = 0 - self.overlayTextNode.lineSpacing = 0.1 - - self.proceedButton = SolidRoundedButtonNode(title: self.presentationData.strings.Premium_Reactions_Proceed, icon: UIImage(bundleImageName: "Premium/ButtonIcon"), theme: SolidRoundedButtonTheme( - backgroundColor: .white, - backgroundColors: [ - UIColor(rgb: 0x0077ff), - UIColor(rgb: 0x6b93ff), - UIColor(rgb: 0x8878ff), - UIColor(rgb: 0xe46ace) - ], foregroundColor: .white), height: 50.0, cornerRadius: 11.0, gloss: true) - - self.cancelButton = HighlightableButtonNode() - self.cancelButton.setTitle(self.presentationData.strings.Common_Cancel, with: Font.regular(17.0), with: self.presentationData.theme.list.itemAccentColor, for: .normal) - - self.carouselNode = ReactionCarouselNode(context: controller.context, theme: controller.presentationData.theme, reactions: controller.reactions) - - super.init() - - self.addSubnode(self.dimNode) - self.addSubnode(self.darkDimNode) - self.addSubnode(self.containerNode) - - self.containerNode.addSubnode(self.proceedButton) - self.containerNode.addSubnode(self.cancelButton) - self.addSubnode(self.carouselNode) - - let textColor: UIColor - if self.presentationData.theme.overallDarkAppearance { - textColor = UIColor(white: 1.0, alpha: 1.0) - self.overlayTextNode.alpha = 0.2 - self.addSubnode(self.overlayTextNode) - } else { - textColor = self.presentationData.theme.contextMenu.secondaryColor - } - self.textNode.attributedText = NSAttributedString(string: self.presentationData.strings.Premium_Reactions_Description, font: Font.regular(17.0), textColor: textColor) - self.overlayTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.Premium_Reactions_Description, font: Font.regular(17.0), textColor: textColor) - - self.proceedButton.pressed = { [weak self] in - if let strongSelf = self, let controller = strongSelf.controller, let navigationController = controller.navigationController { - strongSelf.animateOut() - navigationController.pushViewController(PremiumIntroScreen(context: controller.context, source: .reactions), animated: true) - } - } - - self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) - } - - override func didLoad() { - super.didLoad() - - self.view.insertSubview(self.blurView, aboveSubview: self.dimNode.view) - self.blurView.contentView.addSubview(self.vibrancyView) - - self.vibrancyView.contentView.addSubview(self.textNode.view) - - self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimNodeTap(_:)))) - } - - func updatePresentationData(_ presentationData: PresentationData) { - self.presentationData = presentationData - - self.dimNode.backgroundColor = UIColor(white: self.presentationData.theme.overallDarkAppearance ? 0.0 : 1.0, alpha: 0.5) - self.darkDimNode.backgroundColor = self.presentationData.theme.contextMenu.dimColor - self.blurView.effect = UIBlurEffect(style: self.presentationData.theme.overallDarkAppearance ? .dark : .light) - } - - func animateIn() { - self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - self.blurView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - - self.carouselNode.layer.animatePosition(from: CGPoint(x: 312.0, y: 252.0), to: self.carouselNode.position, duration: 0.45, timingFunction: kCAMediaTimingFunctionSpring) - self.carouselNode.layer.animateScale(from: 0.001, to: 1.0, duration: 0.45, timingFunction: kCAMediaTimingFunctionSpring) - self.carouselNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - self.carouselNode.animateIn() - - self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - self.containerNode.layer.animateScale(from: 0.95, to: 1.0, duration: 0.3) - - self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - self.vibrancyView.layer.animateScale(from: 0.95, to: 1.0, duration: 0.3) - } - - func animateOut() { - self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - self.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - self.carouselNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - self.carouselNode.animateOut() - - self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - self.blurView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.controller?.dismiss(animated: false, completion: nil) - } - }) - } - - func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - self.validLayout = layout - - transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - transition.updateFrame(node: self.darkDimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - transition.updateFrame(view: self.blurView, frame: CGRect(origin: CGPoint(), size: layout.size)) - transition.updateFrame(view: self.vibrancyView, frame: CGRect(origin: CGPoint(), size: layout.size)) - transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - - let carouselFrame = CGRect(origin: CGPoint(x: 0.0, y: 100.0), size: CGSize(width: layout.size.width, height: layout.size.width)) - self.carouselNode.updateLayout(size: carouselFrame.size, transition: transition) - transition.updateFrame(node: self.carouselNode, frame: carouselFrame) - - let sideInset: CGFloat = 16.0 - - let cancelSize = self.cancelButton.measure(layout.size) - self.cancelButton.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - cancelSize.width) / 2.0), y: layout.size.height - cancelSize.height - 49.0), size: cancelSize) - - let buttonWidth = layout.size.width - sideInset * 2.0 - let buttonHeight = self.proceedButton.updateLayout(width: buttonWidth, transition: transition) - self.proceedButton.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - buttonWidth) / 2.0), y: layout.size.height - cancelSize.height - 49.0 - buttonHeight - 23.0), size: CGSize(width: buttonWidth, height: buttonHeight)) - - let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - sideInset * 5.0, height: CGFloat.greatestFiniteMagnitude)) - let _ = self.overlayTextNode.updateLayout(CGSize(width: layout.size.width - sideInset * 5.0, height: CGFloat.greatestFiniteMagnitude)) - let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - textSize.width) / 2.0), y: layout.size.height - cancelSize.height - 48.0 - buttonHeight - 20.0 - textSize.height - 31.0), size: textSize) - self.textNode.frame = textFrame - self.overlayTextNode.frame = textFrame - } - - - @objc private func dimNodeTap(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - self.cancelPressed() - } - } - - @objc private func cancelPressed() { - self.animateOut() - } - } - - private var controllerNode: Node { - return self.displayNode as! Node - } - - public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, reactions: [AvailableReactions.Reaction]) { - self.context = context - self.reactions = reactions - - let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } - self.presentationData = presentationData - self.updatedPresentationData = updatedPresentationData - - super.init(navigationBarPresentationData: nil) - - self.navigationPresentation = .flatModal - - self.statusBar.statusBarStyle = .Ignore - - self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) - - self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) - |> deliverOnMainQueue).start(next: { [weak self] presentationData in - if let strongSelf = self { - let previousTheme = strongSelf.presentationData.theme - let previousStrings = strongSelf.presentationData.strings - - strongSelf.presentationData = presentationData - - if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { - strongSelf.controllerNode.updatePresentationData(strongSelf.presentationData) - } - } - }) - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.presentationDataDisposable?.dispose() - } - - override public func loadDisplayNode() { - self.displayNode = Node(controller: self) - - super.displayNodeDidLoad() - } - - private var didAppear = false - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - if !self.didAppear { - self.didAppear = true - self.controllerNode.animateIn() - } - } - - override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - super.containerLayoutUpdated(layout, transition: transition) - - self.controllerNode.containerLayoutUpdated(layout, transition: transition) - } -} diff --git a/submodules/PremiumUI/Sources/PremiumStarComponent.swift b/submodules/PremiumUI/Sources/PremiumStarComponent.swift index ef55e2e26c..89b6e16870 100644 --- a/submodules/PremiumUI/Sources/PremiumStarComponent.swift +++ b/submodules/PremiumUI/Sources/PremiumStarComponent.swift @@ -7,7 +7,7 @@ import SceneKit import GZip import AppBundle -private let sceneVersion: Int = 1 +private let sceneVersion: Int = 2 private func deg2rad(_ number: Float) -> Float { return number * .pi / 180 @@ -46,13 +46,15 @@ private func generateDiffuseTexture() -> UIImage { class PremiumStarComponent: Component { let isVisible: Bool + let hasIdleAnimations: Bool - init(isVisible: Bool) { + init(isVisible: Bool, hasIdleAnimations: Bool) { self.isVisible = isVisible + self.hasIdleAnimations = hasIdleAnimations } static func ==(lhs: PremiumStarComponent, rhs: PremiumStarComponent) -> Bool { - return lhs.isVisible == rhs.isVisible + return lhs.isVisible == rhs.isVisible && lhs.hasIdleAnimations == rhs.hasIdleAnimations } final class View: UIView, SCNSceneRendererDelegate, ComponentTaggedView { @@ -75,12 +77,14 @@ class PremiumStarComponent: Component { private var previousInteractionTimestamp: Double = 0.0 private var timer: SwiftSignalKit.Timer? + private var hasIdleAnimations = false override init(frame: CGRect) { self.sceneView = SCNView(frame: frame) self.sceneView.backgroundColor = .clear self.sceneView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) self.sceneView.isUserInteractionEnabled = false + self.sceneView.preferredFramesPerSecond = 60 super.init(frame: frame) @@ -210,18 +214,24 @@ class PremiumStarComponent: Component { } private func setup() { - guard let url = getAppBundle().url(forResource: "star", withExtension: ""), - let compressedData = try? Data(contentsOf: url), - let decompressedData = TGGUnzipData(compressedData, 8 * 1024 * 1024) else { - return - } - let fileName = "star_\(sceneVersion).scn" - let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory() + fileName) - if !FileManager.default.fileExists(atPath: tmpURL.path) { - try? decompressedData.write(to: tmpURL) + let resourceUrl: URL + if let url = getAppBundle().url(forResource: "star", withExtension: "scn") { + resourceUrl = url + } else { + let fileName = "star_\(sceneVersion).scn" + let tmpUrl = URL(fileURLWithPath: NSTemporaryDirectory() + fileName) + if !FileManager.default.fileExists(atPath: tmpUrl.path) { + guard let url = getAppBundle().url(forResource: "star", withExtension: ""), + let compressedData = try? Data(contentsOf: url), + let decompressedData = TGGUnzipData(compressedData, 8 * 1024 * 1024) else { + return + } + try? decompressedData.write(to: tmpUrl) + } + resourceUrl = tmpUrl } - guard let scene = try? SCNScene(url: tmpURL, options: nil) else { + guard let scene = try? SCNScene(url: resourceUrl, options: nil) else { return } @@ -249,7 +259,7 @@ class PremiumStarComponent: Component { self.previousInteractionTimestamp = CACurrentMediaTime() self.timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { [weak self] in - if let strongSelf = self { + if let strongSelf = self, strongSelf.hasIdleAnimations { let currentTimestamp = CACurrentMediaTime() if currentTimestamp > strongSelf.previousInteractionTimestamp + 5.0 { strongSelf.playAppearanceAnimation() @@ -359,6 +369,8 @@ class PremiumStarComponent: Component { self.sceneView.bounds = CGRect(origin: .zero, size: CGSize(width: availableSize.width * 2.0, height: availableSize.height * 2.0)) self.sceneView.center = CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0) + self.hasIdleAnimations = component.hasIdleAnimations + return availableSize } } diff --git a/submodules/PremiumUI/Sources/ReactionCarouselNode.swift b/submodules/PremiumUI/Sources/ReactionsCarouselComponent.swift similarity index 79% rename from submodules/PremiumUI/Sources/ReactionCarouselNode.swift rename to submodules/PremiumUI/Sources/ReactionsCarouselComponent.swift index cfec7560c9..788a7d2f4f 100644 --- a/submodules/PremiumUI/Sources/ReactionCarouselNode.swift +++ b/submodules/PremiumUI/Sources/ReactionsCarouselComponent.swift @@ -2,15 +2,90 @@ import Foundation import UIKit import Display import AsyncDisplayKit +import ComponentFlow import TelegramCore import AccountContext import ReactionSelectionNode import TelegramPresentationData import AccountContext +final class ReactionsCarouselComponent: Component { + public typealias EnvironmentType = DemoPageEnvironment + + let context: AccountContext + let theme: PresentationTheme + let reactions: [AvailableReactions.Reaction] + + public init( + context: AccountContext, + theme: PresentationTheme, + reactions: [AvailableReactions.Reaction] + ) { + self.context = context + self.theme = theme + self.reactions = reactions + } + + public static func ==(lhs: ReactionsCarouselComponent, rhs: ReactionsCarouselComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.reactions != rhs.reactions { + return false + } + return true + } + + public final class View: UIView { + private var component: ReactionsCarouselComponent? + private var node: ReactionCarouselNode? + + private var isVisible = false + + public func update(component: ReactionsCarouselComponent, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { + let isDisplaying = environment[DemoPageEnvironment.self].isDisplaying + + if self.node == nil { + let node = ReactionCarouselNode( + context: component.context, + theme: component.theme, + reactions: component.reactions + ) + self.node = node + self.addSubnode(node) + } + + self.component = component + + if let node = self.node { + node.frame = CGRect(origin: CGPoint(x: 0.0, y: -20.0), size: availableSize) + node.updateLayout(size: availableSize, transition: .immediate) + } + + if isDisplaying && !self.isVisible { + self.node?.animateIn() + } + self.isVisible = isDisplaying + + return availableSize + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) + } +} + private let itemSize = CGSize(width: 110.0, height: 110.0) -final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { +private class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { private let context: AccountContext private let theme: PresentationTheme private let reactions: [AvailableReactions.Reaction] @@ -167,7 +242,7 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { func playReaction() { let delta = self.positionDelta - let index = max(0, min(self.itemNodes.count - 1, Int(round(self.currentPosition / delta)))) + let index = max(0, Int(round(self.currentPosition / delta)) % self.itemNodes.count) guard !self.playingIndices.contains(index) else { return @@ -223,7 +298,8 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { self.scrollStartPosition = (scrollView.contentOffset.x, self.currentPosition) } } - + + private let hapticFeedback = HapticFeedback() func scrollViewDidScroll(_ scrollView: UIScrollView) { guard !self.ignoreContentOffsetChange, let (startContentOffset, startPosition) = self.scrollStartPosition else { return @@ -241,10 +317,12 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { self.currentPosition = updatedPosition let indexDelta = self.positionDelta - let index = max(0, min(self.itemNodes.count - 1, Int(round(self.currentPosition / indexDelta)))) + let index = max(0, Int(round(self.currentPosition / indexDelta)) % self.itemNodes.count) if index != self.currentIndex { self.currentIndex = index - print(index) + if self.scrollNode.view.isTracking || self.scrollNode.view.isDecelerating { + self.hapticFeedback.tap() + } } if let size = self.validLayout { @@ -272,7 +350,7 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { self.resetScrollPosition() let delta = self.positionDelta - let index = max(0, min(self.itemNodes.count - 1, Int(round(self.currentPosition / delta)))) + let index = max(0, Int(round(self.currentPosition / delta)) % self.itemNodes.count) self.scrollTo(index, playReaction: true, duration: 0.2) } } @@ -287,14 +365,14 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) if self.scrollNode.view.contentSize.width.isZero { - self.scrollNode.view.contentSize = CGSize(width: 10000000, height: size.height) + self.scrollNode.view.contentSize = CGSize(width: 10000000.0, height: size.height) self.tapNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.view.contentSize) self.resetScrollPosition() } let delta = self.positionDelta - let areaSize = CGSize(width: floor(size.width * 0.7), height: size.height * 0.5) + let areaSize = CGSize(width: floor(size.width * 0.7), height: size.height * 0.45) for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] @@ -326,8 +404,8 @@ final class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { let itemFrame = CGRect(origin: CGPoint(x: size.width * 0.5 + point.x * areaSize.width * 0.5 - itemSize.width * 0.5, y: size.height * 0.5 + point.y * areaSize.height * 0.5 - itemSize.height * 0.5), size: itemSize) containerNode.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) - containerNode.position = itemFrame.center - transition.updateTransformScale(node: containerNode, scale: 1.0 - distance * 0.45) + containerNode.position = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + transition.updateTransformScale(node: containerNode, scale: 1.0 - distance * 0.55) itemNode.frame = CGRect(origin: CGPoint(), size: itemFrame.size) itemNode.updateLayout(size: itemFrame.size, isExpanded: false, largeExpanded: false, isPreviewing: false, transition: transition) diff --git a/submodules/PremiumUI/Sources/RollingCountLabel.swift b/submodules/PremiumUI/Sources/RollingCountLabel.swift index 694ab8030a..48195fe479 100644 --- a/submodules/PremiumUI/Sources/RollingCountLabel.swift +++ b/submodules/PremiumUI/Sources/RollingCountLabel.swift @@ -1,4 +1,5 @@ import UIKit +import Display private extension UILabel { func textWidth() -> CGFloat { @@ -32,22 +33,19 @@ open class RollingLabel: UILabel { private let duration = 1.12 private let durationOffset = 0.2 private let textsNotAnimated = [","] - - public func text(num: Int) { - self.configure(with: num) - self.text = " " - self.animate() + + public func setSuffix(suffix: String) { + self.suffix = suffix } - public func setPrefix(prefix: String) { - self.suffix = prefix - } - - private func configure(with number: Int) { - fullText = String(number) + func configure(with string: String) { + fullText = string clean() setupSubviews() + + self.text = " " + self.animate() } private func animate(ascending: Bool = true) { @@ -99,9 +97,10 @@ open class RollingLabel: UILabel { } stringArray.enumerated().forEach { index, text in - if textsNotAnimated.contains(text) { + let nonDigits = CharacterSet.decimalDigits.inverted + if text.rangeOfCharacter(from: nonDigits) != nil { let label = UILabel() - label.frame.origin = CGPoint(x: x, y: y) + label.frame.origin = CGPoint(x: x, y: y - 1.0 - UIScreenPixel) label.textColor = textColor label.font = font label.text = text @@ -118,28 +117,28 @@ open class RollingLabel: UILabel { label.text = "0" label.textAlignment = .center label.sizeToFit() - createScrollLayer(to: label, text: text) + createScrollLayer(to: label, text: text, index: index) x += label.bounds.width } } } - private func createScrollLayer(to label: UILabel, text: String) { + private func createScrollLayer(to label: UILabel, text: String, index: Int) { let scrollLayer = CAScrollLayer() - scrollLayer.frame = label.frame + scrollLayer.frame = CGRect(x: label.frame.minX, y: label.frame.minY - 10.0, width: label.frame.width, height: label.frame.height * 3.0) scrollLayers.append(scrollLayer) self.layer.addSublayer(scrollLayer) - createContentForLayer(scrollLayer: scrollLayer, text: text) + createContentForLayer(scrollLayer: scrollLayer, text: text, index: index) } - private func createContentForLayer(scrollLayer: CAScrollLayer, text: String) { + private func createContentForLayer(scrollLayer: CAScrollLayer, text: String, index: Int) { var textsForScroll: [String] = [] let max: Int var found = false - if let val = Int(text) { + if let val = Int(text), index == 0 { max = val found = true } else { @@ -150,11 +149,11 @@ open class RollingLabel: UILabel { let str = String(i) textsForScroll.append(str) } - if !found { + if !found && text != "9" { textsForScroll.append(text) } - var height: CGFloat = 0 + var height: CGFloat = 0.0 for text in textsForScroll { let label = UILabel() label.text = text @@ -179,17 +178,18 @@ open class RollingLabel: UILabel { animation.duration = duration + offset animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + let verticalOffset = 20.0 if ascending { - animation.fromValue = maxY + animation.fromValue = maxY + verticalOffset animation.toValue = 0 } else { animation.fromValue = 0 - animation.toValue = maxY + animation.toValue = maxY + verticalOffset } scrollLayer.scrollMode = .vertically scrollLayer.add(animation, forKey: nil) - scrollLayer.scroll(to: CGPoint(x: 0, y: maxY)) + scrollLayer.scroll(to: CGPoint(x: 0, y: maxY + verticalOffset)) offset += self.durationOffset } diff --git a/submodules/PremiumUI/Sources/StickersCarouselComponent.swift b/submodules/PremiumUI/Sources/StickersCarouselComponent.swift new file mode 100644 index 0000000000..8285c81ad6 --- /dev/null +++ b/submodules/PremiumUI/Sources/StickersCarouselComponent.swift @@ -0,0 +1,498 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import ComponentFlow +import TelegramCore +import AccountContext +import ReactionSelectionNode +import TelegramPresentationData +import AccountContext +import AnimatedStickerNode +import TelegramAnimatedStickerNode + +final class StickersCarouselComponent: Component { + public typealias EnvironmentType = DemoPageEnvironment + + let context: AccountContext + let stickers: [TelegramMediaFile] + + public init( + context: AccountContext, + stickers: [TelegramMediaFile] + ) { + self.context = context + self.stickers = stickers + } + + public static func ==(lhs: StickersCarouselComponent, rhs: StickersCarouselComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.stickers != rhs.stickers { + return false + } + return true + } + + public final class View: UIView { + private var component: StickersCarouselComponent? + private var node: StickersCarouselNode? + + public func update(component: StickersCarouselComponent, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { + let isDisplaying = environment[DemoPageEnvironment.self].isDisplaying + + if self.node == nil { + let node = StickersCarouselNode( + context: component.context, + stickers: component.stickers + ) + self.node = node + self.addSubnode(node) + } + + let isFirstTime = self.component == nil + self.component = component + + if let node = self.node { + node.setVisible(isDisplaying) + node.frame = CGRect(origin: .zero, size: availableSize) + node.updateLayout(size: availableSize, transition: .immediate) + } + + if isFirstTime { + self.node?.animateIn() + } + + return availableSize + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) + } +} + +private let itemSize = CGSize(width: 220.0, height: 220.0) + +private class StickerNode: ASDisplayNode { + private let context: AccountContext + private let file: TelegramMediaFile + + public var imageNode: TransformImageNode + public var animationNode: AnimatedStickerNode? + public var additionalAnimationNode: AnimatedStickerNode? + + private let disposable = MetaDisposable() + private let effectDisposable = MetaDisposable() + + init(context: AccountContext, file: TelegramMediaFile) { + self.context = context + self.file = file + + self.imageNode = TransformImageNode() + + if file.isPremiumSticker { + let animationNode = AnimatedStickerNode() + self.animationNode = animationNode + + let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512) + let fittedDimensions = dimensions.cgSize.aspectFitted(CGSize(width: 400.0, height: 400.0)) + + let pathPrefix = context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) + animationNode.setup(source: AnimatedStickerResourceSource(account: self.context.account, resource: file.resource, isVideo: file.isVideoSticker), width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), playbackMode: .loop, mode: .direct(cachePathPrefix: pathPrefix)) + + self.disposable.set(freeMediaFileResourceInteractiveFetched(account: self.context.account, fileReference: .standalone(media: file), resource: file.resource).start()) + + if let effect = file.videoThumbnails.first { + self.effectDisposable.set(freeMediaFileResourceInteractiveFetched(account: self.context.account, fileReference: .standalone(media: file), resource: effect.resource).start()) + + let source = AnimatedStickerResourceSource(account: self.context.account, resource: effect.resource, fitzModifier: nil) + let additionalAnimationNode = AnimatedStickerNode() + + let pathPrefix = context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(effect.resource.id) + additionalAnimationNode.setup(source: source, width: Int(fittedDimensions.width * 2.0), height: Int(fittedDimensions.height * 2.0), playbackMode: .loop, mode: .direct(cachePathPrefix: pathPrefix)) + self.additionalAnimationNode = additionalAnimationNode + } + } else { + self.animationNode = nil + } + + super.init() + + self.isUserInteractionEnabled = false + + if let animationNode = self.animationNode { + self.addSubnode(animationNode) + } else { + self.addSubnode(self.imageNode) + } + + if let additionalAnimationNode = self.additionalAnimationNode { + self.addSubnode(additionalAnimationNode) + } + } + + deinit { + self.disposable.dispose() + self.effectDisposable.dispose() + } + + private var visibility: Bool = false + private var centrality: Bool = false + + public func setCentral(_ central: Bool) { + self.centrality = central + self.updatePlayback() + } + + public func setVisible(_ visible: Bool) { + self.visibility = visible + self.updatePlayback() + } + + private func updatePlayback() { + self.animationNode?.visibility = self.visibility + if let additionalAnimationNode = self.additionalAnimationNode { + let wasVisible = additionalAnimationNode.visibility + let isVisible = self.visibility && self.centrality + if wasVisible && !isVisible { + additionalAnimationNode.alpha = 0.0 + additionalAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak additionalAnimationNode] _ in + additionalAnimationNode?.visibility = isVisible + }) + } else if isVisible { + additionalAnimationNode.visibility = isVisible + if !wasVisible { + additionalAnimationNode.play(fromIndex: 0) + Queue.mainQueue().after(0.05, { + additionalAnimationNode.alpha = 1.0 + }) + } + } + } + } + + public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + let boundingSize = CGSize(width: 240.0, height: 240.0) + + if let dimensitons = self.file.dimensions { + let imageSize = dimensitons.cgSize.aspectFitted(boundingSize) + self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets()))() + let imageFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: 0.0), size: imageSize) + + self.imageNode.frame = imageFrame + if let animationNode = self.animationNode { + animationNode.frame = imageFrame + animationNode.updateLayout(size: imageSize) + + if let additionalAnimationNode = self.additionalAnimationNode { + additionalAnimationNode.frame = imageFrame.offsetBy(dx: -imageFrame.width * 0.245 + 21, dy: -1.0).insetBy(dx: -imageFrame.width * 0.245, dy: -imageFrame.height * 0.245) + additionalAnimationNode.updateLayout(size: additionalAnimationNode.frame.size) + } + } + } + } +} + +private class StickersCarouselNode: ASDisplayNode, UIScrollViewDelegate { + private let context: AccountContext + private let stickers: [TelegramMediaFile] + private var itemContainerNodes: [ASDisplayNode] = [] + private var itemNodes: [StickerNode] = [] + private let scrollNode: ASScrollNode + private let tapNode: ASDisplayNode + + private var animator: DisplayLinkAnimator? + private var currentPosition: CGFloat = 0.0 + private var currentIndex: Int = 0 + + private var validLayout: CGSize? + + private var playingIndices = Set() + + private let positionDelta: Double + + init(context: AccountContext, stickers: [TelegramMediaFile]) { + self.context = context + self.stickers = Array(stickers.shuffled().prefix(14)) + + self.scrollNode = ASScrollNode() + self.tapNode = ASDisplayNode() + + self.positionDelta = 1.0 / CGFloat(self.stickers.count) + + super.init() + + self.clipsToBounds = true + + self.addSubnode(self.scrollNode) + self.scrollNode.addSubnode(self.tapNode) + + self.setup() + } + + override func didLoad() { + super.didLoad() + + self.scrollNode.view.delegate = self + self.scrollNode.view.showsHorizontalScrollIndicator = false + self.scrollNode.view.showsVerticalScrollIndicator = false + self.scrollNode.view.canCancelContentTouches = true + + self.tapNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.stickerTapped(_:)))) + } + + @objc private func stickerTapped(_ gestureRecognizer: UITapGestureRecognizer) { + guard self.animator == nil, self.scrollStartPosition == nil else { + return + } + + let point = gestureRecognizer.location(in: self.view) + guard let index = self.itemContainerNodes.firstIndex(where: { $0.frame.contains(point) }) else { + return + } + + self.scrollTo(index, playAnimation: true, duration: 0.4) + } + + func animateIn() { + self.scrollTo(1, playAnimation: true, duration: 0.5, clockwise: true) + } + + func scrollTo(_ index: Int, playAnimation: Bool, duration: Double, clockwise: Bool? = nil) { + guard index >= 0 && index < self.itemNodes.count else { + return + } + self.currentIndex = index + let delta = self.positionDelta + + let startPosition = self.currentPosition + let newPosition = delta * CGFloat(index) + var change = newPosition - startPosition + if let clockwise = clockwise { + if clockwise { + if change > 0.0 { + change = change - 1.0 + } + } else { + if change < 0.0 { + change = 1.0 + change + } + } + } else { + if change > 0.5 { + change = change - 1.0 + } else if change < -0.5 { + change = 1.0 + change + } + } + + self.animator = DisplayLinkAnimator(duration: duration * UIView.animationDurationFactor(), from: 0.0, to: 1.0, update: { [weak self] t in + let t = listViewAnimationCurveSystem(t) + var updatedPosition = startPosition + change * t + while updatedPosition >= 1.0 { + updatedPosition -= 1.0 + } + while updatedPosition < 0.0 { + updatedPosition += 1.0 + } + self?.currentPosition = updatedPosition + if let size = self?.validLayout { + self?.updateLayout(size: size, transition: .immediate) + } + }, completion: { [weak self] in + self?.animator = nil + if playAnimation { + self?.playSelectedSticker() + } + }) + } + + private var visibility = false + func setVisible(_ visible: Bool) { + guard self.visibility != visible else { + return + } + self.visibility = visible + + if let size = self.validLayout { + self.updateLayout(size: size, transition: .immediate) + } + } + + func setup() { + for sticker in self.stickers { + let containerNode = ASDisplayNode() + let itemNode = StickerNode(context: self.context, file: sticker) + containerNode.isUserInteractionEnabled = false + containerNode.addSubnode(itemNode) + self.addSubnode(containerNode) + + self.itemContainerNodes.append(containerNode) + self.itemNodes.append(itemNode) + } + } + + private var ignoreContentOffsetChange = false + private func resetScrollPosition() { + self.scrollStartPosition = nil + self.ignoreContentOffsetChange = true + self.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: 5000.0 - self.scrollNode.frame.height * 0.5) + self.ignoreContentOffsetChange = false + } + + func playSelectedSticker() { + let delta = self.positionDelta + let index = max(0, Int(round(self.currentPosition / delta)) % self.itemNodes.count) + + guard !self.playingIndices.contains(index) else { + return + } + + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let containerNode = self.itemContainerNodes[i] + let isCentral = i == index + itemNode.setCentral(isCentral) + + if isCentral { + containerNode.view.superview?.bringSubviewToFront(containerNode.view) + } + } + } + + private var scrollStartPosition: (contentOffset: CGFloat, position: CGFloat)? + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if self.scrollStartPosition == nil { + self.scrollStartPosition = (scrollView.contentOffset.y, self.currentPosition) + } + } + + private let hapticFeedback = HapticFeedback() + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !self.ignoreContentOffsetChange, let (startContentOffset, startPosition) = self.scrollStartPosition else { + return + } + + let delta = scrollView.contentOffset.y - startContentOffset + let positionDelta = delta * 0.0005 + var updatedPosition = startPosition + positionDelta + while updatedPosition >= 1.0 { + updatedPosition -= 1.0 + } + while updatedPosition < 0.0 { + updatedPosition += 1.0 + } + self.currentPosition = updatedPosition + + let indexDelta = self.positionDelta + let index = max(0, Int(round(self.currentPosition / indexDelta)) % self.itemNodes.count) + if index != self.currentIndex { + self.currentIndex = index + if self.scrollNode.view.isTracking || self.scrollNode.view.isDecelerating { + self.hapticFeedback.tap() + } + } + + if let size = self.validLayout { + self.updateLayout(size: size, transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard let (startContentOffset, _) = self.scrollStartPosition, abs(velocity.y) > 0.0 else { + return + } + + let delta = self.positionDelta + let scrollDelta = targetContentOffset.pointee.y - startContentOffset + let positionDelta = scrollDelta * 0.0005 + let positionCounts = round(positionDelta / delta) + let adjustedPositionDelta = delta * positionCounts + let adjustedScrollDelta = adjustedPositionDelta * 2000.0 + + targetContentOffset.pointee = CGPoint(x: 0.0, y: startContentOffset + adjustedScrollDelta) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + self.resetScrollPosition() + + let delta = self.positionDelta + let index = max(0, Int(round(self.currentPosition / delta)) % self.itemNodes.count) + self.scrollTo(index, playAnimation: true, duration: 0.2) + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.resetScrollPosition() + self.playSelectedSticker() + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.validLayout = size + + self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) + if self.scrollNode.view.contentSize.width.isZero { + self.scrollNode.view.contentSize = CGSize(width: size.width, height: 10000000.0) + self.tapNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.view.contentSize) + self.resetScrollPosition() + } + + let delta = self.positionDelta + + let bounds = CGRect(origin: .zero, size: size) + let areaSize = CGSize(width: floor(size.width * 4.0), height: size.height * 2.2) + + var visibleCount = 0 + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let containerNode = self.itemContainerNodes[i] + + var angle = CGFloat.pi * 0.5 + CGFloat(i) * delta * CGFloat.pi * 2.0 - self.currentPosition * CGFloat.pi * 2.0 - CGFloat.pi * 0.5 + if angle < 0.0 { + angle = CGFloat.pi * 2.0 + angle + } + if angle > CGFloat.pi * 2.0 { + angle = angle - CGFloat.pi * 2.0 + } + + func calculateRelativeAngle(_ angle: CGFloat) -> CGFloat { + var relativeAngle = angle + if relativeAngle > CGFloat.pi { + relativeAngle = (2.0 * CGFloat.pi - relativeAngle) * -1.0 + } + return relativeAngle + } + + let relativeAngle = calculateRelativeAngle(angle) + let distance = abs(relativeAngle) + + let point = CGPoint( + x: cos(angle), + y: sin(angle) + ) + + let itemFrame = CGRect(origin: CGPoint(x: -size.width - 0.5 * itemSize.width - 30.0 + point.x * areaSize.width * 0.5 - itemSize.width * 0.5, y: size.height * 0.5 + point.y * areaSize.height * 0.5 - itemSize.height * 0.5), size: itemSize) + containerNode.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) + containerNode.position = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + transition.updateTransformScale(node: containerNode, scale: 1.0 - distance * 0.65) + transition.updateAlpha(node: containerNode, alpha: 1.0 - distance * 0.5) + + let isVisible = self.visibility && itemFrame.intersects(bounds) + itemNode.setVisible(isVisible) + if isVisible { + visibleCount += 1 + } + + itemNode.frame = CGRect(origin: CGPoint(), size: itemFrame.size) + itemNode.updateLayout(size: itemFrame.size, transition: transition) + } + } +} diff --git a/submodules/StickerPackPreviewUI/Sources/StickerPreviewPeekContent.swift b/submodules/StickerPackPreviewUI/Sources/StickerPreviewPeekContent.swift index 5f53499926..4532df2191 100644 --- a/submodules/StickerPackPreviewUI/Sources/StickerPreviewPeekContent.swift +++ b/submodules/StickerPackPreviewUI/Sources/StickerPreviewPeekContent.swift @@ -152,10 +152,10 @@ public final class StickerPreviewPeekContentNode: ASDisplayNode, PeekControllerC if isPremiumSticker { animationNode.completed = { [weak self] _ in if let strongSelf = self, let animationNode = strongSelf.animationNode, let additionalAnimationNode = strongSelf.additionalAnimationNode { - Queue.mainQueue().after(0.1, { + Queue.mainQueue().async { animationNode.play() additionalAnimationNode.play() - }) + } } } } diff --git a/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/Contents.json new file mode 100644 index 0000000000..718c1456b3 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "dots@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/dots@3x.png b/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/dots@3x.png new file mode 100644 index 0000000000..57fbe59d71 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Components/Dots.imageset/dots@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Premium/File.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Premium/File.imageset/Contents.json new file mode 100644 index 0000000000..6fe332016d --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Premium/File.imageset/Contents.json @@ -0,0 +1,11 @@ +{ + "images" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 3a4bc9c4eb..1ea74ebed1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1030,9 +1030,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } actions.context = strongSelf.context - - var premiumReactions: [AvailableReactions.Reaction] = [] - + if canAddMessageReactions(message: topMessage), let availableReactions = availableReactions, let allowedReactions = allowedReactions { var hasPremiumPlaceholder = false filterReactions: for reaction in availableReactions.reactions { @@ -1042,9 +1040,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let aroundAnimation = reaction.aroundAnimation else { continue } - if reaction.isPremium { - premiumReactions.append(reaction) - } if !reaction.isEnabled { continue } @@ -1093,9 +1088,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if case .premium = value { - controller?.dismiss() + controller?.dismissWithoutContent() - let controller = PremiumReactionsScreen(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, reactions: premiumReactions) + let context = strongSelf.context + var replaceImpl: ((ViewController) -> Void)? + let controller = PremiumDemoScreen(context: context, subject: .uniqueReactions, action: { + let controller = PremiumIntroScreen(context: context, source: .reactions) + replaceImpl?(controller) + }) + replaceImpl = { [weak controller] c in + controller?.replace(with: c) + } strongSelf.push(controller) return } @@ -11533,7 +11536,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G for item in results { if let item = item { if item.fileSize > Int64(premiumLimits.maxUploadFileParts) * 512 * 1024 { - strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: strongSelf.presentationData.strings.Conversation_PremiumUploadFileTooLarge, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: strongSelf.presentationData.strings.Premium_FileTooLarge, text: strongSelf.presentationData.strings.Conversation_PremiumUploadFileTooLarge, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), in: .window(.root)) return } else if item.fileSize > Int64(limits.maxUploadFileParts) * 512 * 1024 && !isPremium { let context = strongSelf.context diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index b3ed446341..79648135f8 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -522,7 +522,7 @@ func openResolvedUrlImpl(_ resolvedUrl: ResolvedUrl, context: AccountContext, ur |> deliverOnMainQueue).start(next: { peer in let isPremium = peer?.isPremium ?? false if !isPremium { - let controller = PremiumIntroScreen(context: context, reference: reference) + let controller = PremiumIntroScreen(context: context, source: .deeplink(reference)) if let navigationController = navigationController { navigationController.pushViewController(controller, animated: true) } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 36c7b3e802..6a312e9077 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -159,11 +159,14 @@ public struct WebAppParameters { public func generateWebAppThemeParams(_ presentationTheme: PresentationTheme) -> [String: Any] { var backgroundColor = presentationTheme.list.plainBackgroundColor.rgb + var secondaryBackgroundColor = presentationTheme.list.blocksBackgroundColor.rgb if backgroundColor == 0x000000 { backgroundColor = presentationTheme.list.itemBlocksBackgroundColor.rgb + secondaryBackgroundColor = presentationTheme.list.itemBlocksBackgroundColor.rgb } return [ "bg_color": Int32(bitPattern: backgroundColor), + "secondary_bg_color": Int32(bitPattern: secondaryBackgroundColor), "text_color": Int32(bitPattern: presentationTheme.list.itemPrimaryTextColor.rgb), "hint_color": Int32(bitPattern: presentationTheme.list.itemSecondaryTextColor.rgb), "link_color": Int32(bitPattern: presentationTheme.list.itemAccentColor.rgb), @@ -183,6 +186,10 @@ public final class WebAppController: ViewController, AttachmentContainable { fileprivate class Node: ViewControllerTracingNode, WKNavigationDelegate, WKUIDelegate, UIScrollViewDelegate { private weak var controller: WebAppController? + private let backgroundNode: ASDisplayNode + private let headerBackgroundNode: ASDisplayNode + private let topOverscrollNode: ASDisplayNode + fileprivate var webView: WebAppWebView? private var placeholderIcon: (UIImage, Bool)? private var placeholderNode: ShimmerEffectNode? @@ -210,6 +217,10 @@ public final class WebAppController: ViewController, AttachmentContainable { self.controller = controller self.presentationData = controller.presentationData + self.backgroundNode = ASDisplayNode() + self.headerBackgroundNode = ASDisplayNode() + self.topOverscrollNode = ASDisplayNode() + super.init() if self.presentationData.theme.list.plainBackgroundColor.rgb == 0x000000 { @@ -241,6 +252,9 @@ public final class WebAppController: ViewController, AttachmentContainable { self.addSubnode(placeholderNode) self.placeholderNode = placeholderNode + self.addSubnode(self.backgroundNode) + self.addSubnode(self.headerBackgroundNode) + let placeholder: Signal<(FileMediaReference, Bool)?, NoError> if durgerKingBotIds.contains(controller.botId.id._internalGetInt64Value()) { placeholder = .single(nil) @@ -375,6 +389,7 @@ public final class WebAppController: ViewController, AttachmentContainable { return } self.view.addSubview(webView) + webView.scrollView.insertSubview(self.topOverscrollNode.view, at: 0) } @objc fileprivate func mainButtonPressed() { @@ -468,6 +483,10 @@ public final class WebAppController: ViewController, AttachmentContainable { let previousLayout = self.validLayout?.0 self.validLayout = (layout, navigationBarHeight) + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: .zero, size: layout.size)) + transition.updateFrame(node: self.headerBackgroundNode, frame: CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: navigationBarHeight))) + transition.updateFrame(node: self.topOverscrollNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -1000.0), size: CGSize(width: layout.size.width, height: 1000.0))) + if let webView = self.webView { let frame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: navigationBarHeight), size: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: max(1.0, layout.size.height - navigationBarHeight - layout.intrinsicInsets.bottom))) let viewportFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: navigationBarHeight), size: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: max(1.0, layout.size.height - navigationBarHeight - layout.intrinsicInsets.bottom - layout.additionalInsets.bottom))) @@ -666,11 +685,46 @@ public final class WebAppController: ViewController, AttachmentContainable { break } } + case "web_app_set_background_color": + if let json = json, let colorValue = json["color"] as? String, let color = UIColor(hexString: colorValue) { + let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear) + transition.updateBackgroundColor(node: self.backgroundNode, color: color) + } + case "web_app_set_header_color": + if let json = json, let colorKey = json["color_key"] as? String, ["bg_color", "secondary_bg_color"].contains(colorKey) { + self.headerColorKey = colorKey + self.updateHeaderBackgroundColor(transition: .animated(duration: 0.2, curve: .linear)) + } default: break } } + private var headerColorKey: String? + private func updateHeaderBackgroundColor(transition: ContainedViewLayoutTransition) { + let color: UIColor? + var backgroundColor = self.presentationData.theme.list.plainBackgroundColor + var secondaryBackgroundColor = self.presentationData.theme.list.blocksBackgroundColor + if backgroundColor.rgb == 0x000000 { + backgroundColor = self.presentationData.theme.list.itemBlocksBackgroundColor + secondaryBackgroundColor = self.presentationData.theme.list.itemBlocksBackgroundColor + } + if let headerColorKey = self.headerColorKey { + switch headerColorKey { + case "bg_color": + color = backgroundColor + case "secondary_bg_color": + color = secondaryBackgroundColor + default: + color = nil + } + } else { + color = nil + } + transition.updateBackgroundColor(node: self.headerBackgroundNode, color: color ?? .clear) + transition.updateBackgroundColor(node: self.topOverscrollNode, color: color ?? .clear) + } + private func handleSendData(data string: String) { guard let controller = self.controller, let buttonText = controller.buttonText, !self.dismissed else { return @@ -699,6 +753,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } else { self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor } + self.updateHeaderBackgroundColor(transition: .immediate) self.sendThemeChangedEvent() } diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index 61aa444e20..6c494aa817 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -55,7 +55,7 @@ final class WebAppWebView: WKWebView { handleScriptMessageImpl?(message) }, name: "performAction") - let selectionString = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea){-webkit-user-select:none;}';" + let selectionString = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea):not([\"contenteditable\"=\"true\"]){-webkit-user-select:none;}';" + " var head = document.head || document.getElementsByTagName('head')[0];" + " var style = document.createElement('style'); style.type = 'text/css';" + " style.appendChild(document.createTextNode(css)); head.appendChild(style);"