diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index 7436c56971..728e56f64e 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -654,13 +654,16 @@ public final class ContextControllerActionsListActionItemNode: HighlightTracking } if let additionalIconSize { - let iconFrame = CGRect( + var iconFrame = CGRect( origin: CGPoint( x: 10.0, y: floor((size.height - additionalIconSize.height) / 2.0) ), size: additionalIconSize ) + if self.item.iconPosition == .left { + iconFrame.origin.x = size.width - additionalIconSize.width - 10.0 + } transition.updateFrame(node: self.additionalIconNode, frame: iconFrame, beginWithCurrentState: true) } }) diff --git a/submodules/ContextUI/Sources/ContextSourceContainer.swift b/submodules/ContextUI/Sources/ContextSourceContainer.swift index 2cf1f54ba0..c5e5101c6c 100644 --- a/submodules/ContextUI/Sources/ContextSourceContainer.swift +++ b/submodules/ContextUI/Sources/ContextSourceContainer.swift @@ -672,6 +672,7 @@ final class ContextSourceContainer: ASDisplayNode { foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8), selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1) ), + theme: presentationData.theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(14.0), spacing: 9.0 diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift index d197166f39..baffc6678d 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift @@ -152,6 +152,7 @@ final class HashtagSearchNavigationContentNode: NavigationBarContentNode { foreground: self.theme.list.itemSecondaryTextColor, selection: self.theme.list.itemAccentColor ), + theme: self.theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(14.0), spacing: self.hasCurrentChat ? 24.0 : 8.0, diff --git a/submodules/ItemListUI/Sources/ItemListControllerSegmentedTitleView.swift b/submodules/ItemListUI/Sources/ItemListControllerSegmentedTitleView.swift index a74a317d8e..f2d0b36fbe 100644 --- a/submodules/ItemListUI/Sources/ItemListControllerSegmentedTitleView.swift +++ b/submodules/ItemListUI/Sources/ItemListControllerSegmentedTitleView.swift @@ -82,6 +82,7 @@ public final class ItemListControllerSegmentedTitleView: UIView { foreground: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8), selection: self.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05) ), + theme: self.theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(15.0), spacing: 8.0 diff --git a/submodules/ItemListUI/Sources/ItemListControllerTabsContentNode.swift b/submodules/ItemListUI/Sources/ItemListControllerTabsContentNode.swift index cb68336005..639acc53b9 100644 --- a/submodules/ItemListUI/Sources/ItemListControllerTabsContentNode.swift +++ b/submodules/ItemListUI/Sources/ItemListControllerTabsContentNode.swift @@ -84,6 +84,7 @@ final class ItemListControllerTabsContentNode: NavigationBarContentNode { foreground: self.theme.list.itemSecondaryTextColor, selection: self.theme.list.itemAccentColor ), + theme: self.theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(14.0), spacing: 48.0, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index aa1097656e..c0c957fe6d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -1424,6 +1424,37 @@ public final class PeerStoryListContext: StoryListContext { }).start() } + func reorderFolders(ids: [Int64]) { + var state = self.stateValue + var folders: [State.Folder] = [] + for id in ids { + if !folders.contains(where: { $0.id == id }), let folder = state.availableFolders.first(where: { $0.id == id }) { + folders.append(folder) + } + } + for folder in state.availableFolders { + if !folders.contains(where: { $0.id == folder.id }) { + folders.append(folder) + } + } + state.availableFolders = folders + self.stateValue = state + + let peerId = self.peerId + let isArchived = self.isArchived + let items = state.items + let pinnedIds = state.pinnedIds + let totalCount = state.totalCount + let _ = (self.account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: isArchived ? 1 : 0) + if let entry = CodableEntry(CachedPeerStoryListHead(items: items.prefix(100).map { .item($0.storyItem.asStoryItem()) }, pinnedIds: pinnedIds, totalCount: Int32(totalCount), folders: folders)) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + } + func addToFolder(id: Int64, items: [EngineStoryItem]) { let peerId = self.peerId let _ = (self.account.postbox.transaction { transaction -> Void in @@ -1657,6 +1688,12 @@ public final class PeerStoryListContext: StoryListContext { } } + public func reorderFolders(ids: [Int64]) { + self.impl.with { impl in + impl.reorderFolders(ids: ids) + } + } + public func addToFolder(id: Int64, items: [EngineStoryItem]) { self.impl.with { impl in impl.addToFolder(id: id, items: items) @@ -1675,6 +1712,27 @@ public final class PeerStoryListContext: StoryListContext { } } + public static func addFolderExternal(account: Account, peerId: PeerId, title: String) -> Int64 { + let id = Int64.random(in: Int64.min ... Int64.max) + + let _ = (account.postbox.transaction { transaction -> Void in + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId.toInt64()) + key.setInt8(8, value: 0) + + + let cached = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key))?.get(CachedPeerStoryListHead.self) + var folders = cached?.folders ?? [] + folders.append(StoryListContextState.Folder(id: id, title: title)) + + if let entry = CodableEntry(CachedPeerStoryListHead(items: cached?.items ?? [], pinnedIds: cached?.pinnedIds ?? [], totalCount: cached?.totalCount ?? 0, folders: folders)) { + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedPeerStoryListHeads, key: key), entry: entry) + } + }).start() + + return id + } + public static func folderPreviews(peerId: EnginePeer.Id, account: Account) -> Signal<(peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]), NoError> { return account.postbox.transaction { transaction -> (peer: PeerReference, [(folder: StoryListContext.State.Folder, item: EngineStoryItem?)]) in let key = ValueBoxKey(length: 8 + 1) diff --git a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift index f1579efcf9..998507837e 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftOptionsScreen/Sources/GiftOptionsScreen.swift @@ -1382,6 +1382,7 @@ final class GiftOptionsScreenComponent: Component { selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), simple: true ), + theme: theme, items: tabSelectorItems, selectedId: AnyHashable(self.starsFilter.rawValue), setSelectedId: { [weak self] id in diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift index 7a1c065123..40d8136193 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoRatingComponent/Sources/PeerInfoRatingComponent.swift @@ -271,7 +271,7 @@ public final class PeerInfoRatingComponent: Component { transition.setFrame(view: shimmerEffectView, frame: backgroundFrame) alphaTransition.setAlpha(view: shimmerEffectView, alpha: 1.0) - shimmerEffectView.update(color: .clear, borderColor: component.foregroundColor, rect: CGRect(origin: CGPoint(), size: backgroundFrame.size), path: UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: backgroundFrame.size), cornerRadius: backgroundFrame.height * 0.5).cgPath, transition: shimmerEffectTransition.containedViewLayoutTransition) + shimmerEffectView.update(color: .clear, borderColor: component.foregroundColor, rect: CGRect(origin: CGPoint(), size: backgroundFrame.size), path: UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: backgroundFrame.size).insetBy(dx: 1.0, dy: 1.0), cornerRadius: backgroundFrame.height * 0.5).cgPath, transition: shimmerEffectTransition.containedViewLayoutTransition) } else if let shimmerEffectView = self.shimmerEffectView { self.shimmerEffectView = nil @@ -325,7 +325,7 @@ public final class PeerInfoRatingComponent: Component { tooltipController.view.isUserInteractionEnabled = false } - transition.setFrame(view: tooltipController.view, frame: CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -200.0 * 0.5 + foregroundFrame.width - 7.0, dy: -200.0 * 0.5)) + transition.setFrame(view: tooltipController.view, frame: CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -200.0 * 0.5 + foregroundFrame.width + 2.0, dy: -200.0 * 0.5)) alphaTransition.setAlpha(view: tooltipController.view, alpha: component.isExpanded ? 1.0 : 0.0) return size diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index bbaacddc33..42bec125e2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -39,6 +39,7 @@ import PeerInfoCoverComponent import PeerInfoPaneNode import MultilineTextComponent import PeerInfoRatingComponent +import UndoUI final class PeerInfoHeaderNavigationTransition { let sourceNavigationBar: NavigationBar @@ -132,6 +133,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { var titleExpandedStatusIconSize: CGSize? var subtitleRatingIsExpanded: Bool = false + var didDisplayRatingTooltip: Bool = false var subtitleRating: ComponentView? let subtitleNodeContainer: ASDisplayNode @@ -1973,6 +1975,23 @@ final class PeerInfoHeaderNode: ASDisplayNode { } self.subtitleRatingIsExpanded = !self.subtitleRatingIsExpanded self.requestUpdateLayout?(true) + + if self.subtitleRatingIsExpanded, let controller = self.controller, let presentationData = self.presentationData, !self.didDisplayRatingTooltip { + self.didDisplayRatingTooltip = true + controller.presentInGlobalOverlay(UndoOverlayController( + presentationData: presentationData, + content: .info( + title: nil, + text: "Profile level reflects the user's payment reliability", + timeout: 4.0, + customUndoText: "Learn More" + ), + position: .top, + action: { _ in + return true + } + )) + } } )), environment: {}, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index f8b0ce7395..6814d08189 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -3140,7 +3140,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.updateSelectedItems(animated: false) if let gridSnapshot { - self.view.insertSubview(gridSnapshot, aboveSubview: self.itemGrid.view) + self.view.insertSubview(gridSnapshot, aboveSubview: self.contextGestureContainerNode.view) gridSnapshot.frame = self.itemGrid.frame if let animateDirection { let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0 @@ -3157,7 +3157,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } if let emptyStateSnapshot, let animateDirection { - self.view.insertSubview(emptyStateSnapshot, belowSubview: self.itemGrid.view) + self.view.insertSubview(emptyStateSnapshot, belowSubview: self.contextGestureContainerNode.view) let directionFactor: CGFloat = animateDirection ? 1.0 : -1.0 let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) @@ -3812,7 +3812,75 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr for folder in self.currentStoryFolders { folderItems.append(TabSelectorComponent.Item( id: AnyHashable(folder.id), - title: folder.title + title: folder.title, + isReorderable: self.canManageStories, + contextAction: self.canManageStories ? { [weak self] sourceNode, gesture in + guard let self else { + return + } + guard let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { + return + } + guard let controller = self.parentController else { + return + } + + var items: [ContextMenuItem] = [] + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Add Stories", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/AddStoryIcon"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + guard let self else { + a(.default) + return + } + + a(.default) + + self.presentAddStoriesToFolder() + }))) + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Reorder", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, a in + guard let self else { + a(.default) + return + } + + a(.default) + + self.beginReordering() + }))) + + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Delete Album", textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in + guard let self else { + f(.default) + return + } + + f(.dismissWithoutContent) + + self.presentDeleteStoryFolder(id: folder.id) + }))) + + let presentationData = self.presentationData + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ItemExtractedContentSource( + sourceNode: sourceNode, + containerView: controller.view, + keepInPlace: false + )), + items: .single(ContextController.Items(content: .list(items))), + recognizer: nil, + gesture: gesture + ) + controller.presentInGlobalOverlay(contextController) + } : nil )) } } @@ -3834,6 +3902,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr foreground: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.8), selection: self.presentationData.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05) ), + theme: self.presentationData.theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(14.0), spacing: 9.0, @@ -3841,6 +3910,30 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr ), items: folderItems, selectedId: selectedId, + reorderItem: self.isReordering ? { [weak self] fromId, toId in + guard let self else { + return + } + guard let sourceId = fromId.base as? Int64 else { + return + } + guard let targetId = toId.base as? Int64 else { + return + } + guard let sourceIndex = self.currentStoryFolders.firstIndex(where: { $0.id == sourceId }), let targetIndex = self.currentStoryFolders.firstIndex(where: { $0.id == targetId }) else { + return + } + let folder = self.currentStoryFolders[sourceIndex] + if targetIndex < sourceIndex { + self.currentStoryFolders.remove(at: sourceIndex) + self.currentStoryFolders.insert(folder, at: targetIndex) + } else { + self.currentStoryFolders.insert(folder, at: targetIndex + 1) + self.currentStoryFolders.remove(at: sourceIndex) + } + + self.update(transition: .animated(duration: 0.2, curve: .easeInOut)) + } : nil, setSelectedId: { [weak self] id in guard let self else { return @@ -3887,8 +3980,23 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.view.addSubview(folderTabView) } transition.updateFrame(view: folderTabView, frame: folderTabFrame) - transition.updateAlpha(layer: folderTabView.layer, alpha: isSelectingOrReordering ? 0.5 : 1.0) - folderTabView.isUserInteractionEnabled = !isSelectingOrReordering + + var areTabsDisabled = false + + if case .botPreview = self.scope { + if isSelectingOrReordering { + areTabsDisabled = true + } + } else { + if self.itemInteraction.selectedIds != nil { + areTabsDisabled = true + } + } + + transition.updateAlpha(layer: folderTabView.layer, alpha: areTabsDisabled ? 0.5 : 1.0) + folderTabView.isUserInteractionEnabled = !areTabsDisabled + + folderTabView.disablesInteractiveTransitionGestureRecognizer = self.isReordering } } @@ -4033,7 +4141,12 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } if displayFolderTab { - updateFolderTab(size: size, topInset: topInset, transition: transition) + var folderTabsTransition = transition + if animateBottomPanel && !folderTabsTransition.isAnimated { + folderTabsTransition = .animated(duration: 0.4, curve: .spring) + } + + updateFolderTab(size: size, topInset: topInset, transition: folderTabsTransition) gridTopInset += 50.0 if case .botPreview = self.scope { @@ -4828,17 +4941,28 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr public func presentDeleteCurrentStoryFolder() { if let folder = self.currentStoryFolder { + self.presentDeleteStoryFolder(id: folder.id) + } + } + + private func presentDeleteStoryFolder(id: Int64) { + guard let folder = self.currentStoryFolders.first(where: { $0.id == id }) else { + return + } + let _ = folder + + if self.currentStoryFolder?.id == id { self.setStoryFolder(id: nil, assumeEmpty: false) - self.currentStoryFolders.removeAll(where: { $0.id == folder.id }) - self.removedStoryFolders.insert(folder.id) - - if let listContext = self.listSource as? PeerStoryListContext { - listContext.removeFolder(id: folder.id) - } - - if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { - self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate) - } + } + self.currentStoryFolders.removeAll(where: { $0.id == id }) + self.removedStoryFolders.insert(id) + + if let listContext = self.listSource as? PeerStoryListContext { + listContext.removeFolder(id: id) + } + + if let (size, topInset, sideInset, bottomInset, deviceMetrics, visibleHeight, isScrollingLockedAtTop, expandProgress, navigationHeight, presentationData) = self.currentParams { + self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, deviceMetrics: deviceMetrics, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, navigationHeight: navigationHeight, presentationData: presentationData, synchronous: false, transition: .immediate) } } @@ -4985,6 +5109,12 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.itemGrid.setReordering(isReordering: isReordering) + if !isReordering && !self.currentStoryFolders.isEmpty && self.canManageStories { + if let listSource = self.listSource as? PeerStoryListContext { + listSource.reorderFolders(ids: self.currentStoryFolders.map(\.id)) + } + } + if !isReordering, let reorderedIds = self.reorderedIds { self.reorderedIds = nil if case .botPreview = self.scope, let listSource = self.listSource as? BotPreviewStoryListContext { @@ -5377,3 +5507,34 @@ private final class BottomActionsPanelComponent: Component { } } +private final class ItemExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool + let ignoreContentTouches: Bool = true + let blurBackground: Bool = true + let adjustContentForSideInset: Bool = true + + private let sourceNode: ContextExtractedContentContainingNode + private weak var containerView: UIView? + + init(sourceNode: ContextExtractedContentContainingNode, containerView: UIView, keepInPlace: Bool) { + self.sourceNode = sourceNode + self.containerView = containerView + self.keepInPlace = keepInPlace + } + + func takeView() -> ContextControllerTakeViewInfo? { + var contentArea: CGRect? + if let containerView = self.containerView { + contentArea = containerView.convert(containerView.bounds, to: nil) + } + + return ContextControllerTakeViewInfo( + containingItem: .node(self.sourceNode), + contentAreaInScreenSpace: contentArea ?? UIScreen.main.bounds + ) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD index b5a0f4e959..74e98e8cb6 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/BUILD @@ -42,6 +42,10 @@ swift_library( "//submodules/UndoUI", "//submodules/TemporaryCachedPeerDataManager", "//submodules/CountrySelectionUI", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/ContextUI", + "//submodules/PromptUI", + "//submodules/DirectMediaImageCache", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift index 13c4b9cd64..cf04937b71 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -24,6 +24,11 @@ import Markdown import TelegramUIPreferences import UndoUI import TelegramStringFormatting +import ListActionItemComponent +import ContextUI +import BundleIconComponent +import PromptUI +import DirectMediaImageCache final class ShareWithPeersScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -349,6 +354,8 @@ final class ShareWithPeersScreenComponent: Component { private var selectedCategories = Set() private var selectedOptions = Set() + private var shareToFolders: [StoryListContext.State.Folder] = [] + private var component: ShareWithPeersScreenComponent? private weak var state: EmptyComponentState? private var environment: ViewControllerComponentContainer.Environment? @@ -877,6 +884,139 @@ final class ShareWithPeersScreenComponent: Component { } } + private func displayFolderSelectionMenu(sourceView: UIView) { + Task { @MainActor [weak self] in + guard let self, let component = self.component, let environment = self.environment, let controller = environment.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let (peerReference, folderPreviews) = await PeerStoryListContext.folderPreviews(peerId: component.context.account.peerId, account: component.context.account).get() + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: "New Album", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Folder"), color: theme.contextMenu.primaryColor) }, iconPosition: .left, action: { [weak self] c, f in + guard let self else { + f(.default) + return + } + + c?.dismiss(completion: { [weak self] in + guard let self else { + return + } + self.presentAddStoryFolder() + }) + }))) + + for folderPreview in folderPreviews { + var iconSource: ContextMenuActionItemIconSource? + if let story = folderPreview.item { + var imageSignal: Signal? + + var selectedMedia: Media? + if let image = story.media._asMedia() as? TelegramMediaImage { + selectedMedia = image + } else if let file = story.media._asMedia() as? TelegramMediaFile { + selectedMedia = file + } + + if let selectedMedia { + let directMediaImageCache = DirectMediaImageCache(account: component.context.account) + if let result = directMediaImageCache.getImage(peer: peerReference, story: story, media: selectedMedia, width: 24, aspectRatio: 1.0, possibleWidths: [24], includeBlurred: false, synchronous: true) { + if let loadSignal = result.loadSignal { + imageSignal = .single(result.image) |> then(loadSignal) + } else { + imageSignal = .single(result.image) + } + } + } + + if let imageSignal { + iconSource = ContextMenuActionItemIconSource( + size: CGSize(width: 24.0, height: 24.0), + cornerRadius: 5.0, + signal: imageSignal + ) + } + } + + var icon: (PresentationTheme) -> UIImage? = { _ in nil } + if iconSource == nil { + icon = { theme in + return generateImage(CGSize(width: 24.0, height: 24.0), opaque: false, scale: nil, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(theme.contextMenu.primaryColor.withMultipliedAlpha(0.1).cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 5.0).cgPath) + context.fillPath() + }) + } + } + + let isSelected = self.shareToFolders.contains(where: { $0.id == folderPreview.folder.id }) + items.append(.action(ContextMenuActionItem(text: folderPreview.folder.title, icon: icon, additionalLeftIcon: { theme in + if !isSelected { + return nil + } + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + }, iconSource: iconSource, iconPosition: .left, action: { [weak self] c, f in + guard let self, let _ = self.component else { + f(.default) + return + } + + c?.dismiss(completion: {}) + + if let index = self.shareToFolders.firstIndex(where: { $0.id == folderPreview.folder.id }) { + self.shareToFolders.remove(at: index) + } else { + self.shareToFolders.append(folderPreview.folder) + } + + self.state?.updated(transition: .immediate) + }))) + } + + let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceView: sourceView, actionsOnTop: true)), items: .single(ContextController.Items(id: AnyHashable(0), content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + } + + private func presentAddStoryFolder() { + guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + //TODO:localize + let promptController = promptController( + sharedContext: component.context.sharedContext, + updatedPresentationData: (initial: presentationData, signal: .single(presentationData)), + text: "Create a New Album", + titleFont: .bold, + subtitle: "Choose a name for your album and start adding your stories there.", + value: "", + placeholder: "Title", + characterLimit: 20, + apply: { [weak self] value in + guard let self, let component = self.component else { + return + } + if let value, !value.isEmpty { + let id = PeerStoryListContext.addFolderExternal(account: component.context.account, peerId: component.context.account.peerId, title: value) + self.shareToFolders.append(StoryListContext.State.Folder( + id: id, + title: value + )) + self.state?.updated(transition: .immediate) + } + } + ) + controller.present(promptController, in: .window(.root)) + } + private func updateScrolling(transition: ComponentTransition) { guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else { return @@ -1683,8 +1823,84 @@ final class ShareWithPeersScreenComponent: Component { } sectionOffset += footerSize.height } else if section.id == 4 && section.itemCount > 0 { + var sectionItemOffset: CGFloat = 0.0 + if self.selectedOptions.contains(.pin) { + let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + sectionItemOffset), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight)) + if !visibleBounds.intersects(itemFrame) { + continue + } + + let itemId = AnyHashable("album") + validIds.append(itemId) + + var itemTransition = transition + let visibleItem: ComponentView + if let current = self.visibleItems[itemId] { + visibleItem = current + } else { + visibleItem = ComponentView() + if !transition.animation.isImmediate { + itemTransition = .immediate + } + self.visibleItems[itemId] = visibleItem + } + + //TODO:localize + var foldersText = "All Stories" + if !self.shareToFolders.isEmpty { + if self.shareToFolders.count == 1 { + foldersText = self.shareToFolders[0].title + } else { + foldersText = "\(self.shareToFolders.count) Albums" + } + } + + //TODO:localize + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + background: nil, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Album", + font: Font.regular(17.0), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(AlbumLabelComponent( + theme: environment.theme, + title: foldersText, + action: { [weak self] sourceView in + guard let self else { + return + } + self.displayFolderSelectionMenu(sourceView: sourceView) + } + ))), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0), isInteractive: true)), + action: nil + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemView = visibleItem.view { + if itemView.superview == nil { + if let minSectionHeader { + self.itemContainerView.insertSubview(itemView, belowSubview: minSectionHeader) + } else { + self.itemContainerView.addSubview(itemView) + } + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + sectionItemOffset += section.itemHeight + } if let item = component.coverItem { - let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + CGFloat(0) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight)) + let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + sectionItemOffset), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight)) if !visibleBounds.intersects(itemFrame) { continue } @@ -1750,6 +1966,9 @@ final class ShareWithPeersScreenComponent: Component { if let sendAsPeerId = self.sendAsPeerId, sendAsPeerId.isGroupOrChannel == true { footerText = isSendAsGroup ? environment.strings.Story_Privacy_ChooseCoverGroupInfo : environment.strings.Story_Privacy_ChooseCoverChannelInfo } + if component.coverItem == nil { + footerText = "Choose the albums where you want to share your story." + } let footerSize = sectionFooter.update( transition: sectionFooterTransition, @@ -2496,7 +2715,14 @@ final class ShareWithPeersScreenComponent: Component { itemCount: component.optionItems.count )) - if hasCover { + if hasCover || self.selectedOptions.contains(.pin) { + var itemCount = 0 + if hasCover { + itemCount += 1 + } + if self.selectedOptions.contains(.pin) { + itemCount += 1 + } sections.append(ItemLayout.Section( id: 4, insets: UIEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0), @@ -3303,3 +3529,131 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { } } } + +final class HeaderContextReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceView: UIView + private let actionsOnTop: Bool + + init(controller: ViewController, sourceView: UIView, actionsOnTop: Bool) { + self.controller = controller + self.sourceView = sourceView + self.actionsOnTop = actionsOnTop + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: self.actionsOnTop ? .top : .bottom) + } +} + +private final class AlbumLabelComponent: Component { + let theme: PresentationTheme + let title: String + let action: (UIView) -> Void + + init( + theme: PresentationTheme, + title: String, + action: @escaping (UIView) -> Void + ) { + self.theme = theme + self.title = title + self.action = action + } + + static func ==(lhs: AlbumLabelComponent, rhs: AlbumLabelComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.title != rhs.title { + return false + } + return true + } + + final class View: HighlightableButton { + private let title = ComponentView() + private var selectorIcon: ComponentView? + + private var component: AlbumLabelComponent? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + + self.isUserInteractionEnabled = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action(self) + } + + func update(component: AlbumLabelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + let height: CGFloat = 44.0 + + let rightTextInset: CGFloat = 24.0 + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - rightTextInset, height: height) + ) + let titleFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((height - titleSize.height) * 0.5)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + titleView.frame = titleFrame + } + + let size = CGSize(width: titleSize.width + rightTextInset, height: height) + + let selectorIcon: ComponentView + if let current = self.selectorIcon { + selectorIcon = current + } else { + selectorIcon = ComponentView() + self.selectorIcon = selectorIcon + } + let selectorIconSize = selectorIcon.update( + transition: transition, + component: AnyComponent(BundleIconComponent( + name: "Item List/ExpandableSelectorArrows", tintColor: component.theme.list.itemSecondaryTextColor)), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let selectorIconFrame = CGRect(origin: CGPoint(x: size.width - 8.0 - selectorIconSize.width, y: floorToScreenPixels((size.height - selectorIconSize.height) * 0.5)), size: selectorIconSize) + if let selectorIconView = selectorIcon.view { + if selectorIconView.superview == nil { + selectorIconView.isUserInteractionEnabled = false + self.addSubview(selectorIconView) + } + transition.setFrame(view: selectorIconView, frame: selectorIconFrame) + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift index b101e9d4c9..59ccbe39e3 100644 --- a/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen/Sources/StarsWithdrawalScreen.swift @@ -348,6 +348,7 @@ private final class SheetContent: CombinedComponent { selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), simple: true ), + theme: theme, customLayout: TabSelectorComponent.CustomLayout( font: Font.medium(14.0), spacing: 10.0 diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift index 39bf05b8cb..11b8786589 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -1604,6 +1604,7 @@ final class StoryItemSetViewListComponent: Component { foreground: .white, selection: UIColor(rgb: 0xffffff, alpha: 0.09) ), + theme: component.theme, items: [ TabSelectorComponent.Item( id: AnyHashable(ListMode.everyone.rawValue), diff --git a/submodules/TelegramUI/Components/TabSelectorComponent/BUILD b/submodules/TelegramUI/Components/TabSelectorComponent/BUILD index e5a61ad4ba..62f37931c1 100644 --- a/submodules/TelegramUI/Components/TabSelectorComponent/BUILD +++ b/submodules/TelegramUI/Components/TabSelectorComponent/BUILD @@ -10,12 +10,14 @@ swift_library( "-warnings-as-errors", ], deps = [ + "//submodules/AsyncDisplayKit", "//submodules/Display", "//submodules/ComponentFlow", "//submodules/TelegramUI/Components/PlainButtonComponent", "//submodules/Components/MultilineTextWithEntitiesComponent", "//submodules/TextFormat", - "//submodules/AccountContext" + "//submodules/AccountContext", + "//submodules/TelegramPresentationData", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift index 80e4ff12df..53502dd20c 100644 --- a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift +++ b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift @@ -1,11 +1,13 @@ import Foundation import UIKit import Display +import AsyncDisplayKit import ComponentFlow import PlainButtonComponent import MultilineTextWithEntitiesComponent import TextFormat import AccountContext +import TelegramPresentationData public final class TabSelectorComponent: Component { public final class ItemEnvironment: Equatable { @@ -57,53 +59,83 @@ public final class TabSelectorComponent: Component { } } - public struct Item: Equatable { + public final class Item: Equatable { public enum Content: Equatable { case text(String) case component(AnyComponent) } - public var id: AnyHashable - public var content: Content + public let id: AnyHashable + public let content: Content + public let isReorderable: Bool + public let contextAction: ((ASDisplayNode, ContextGesture) -> Void)? public init( id: AnyHashable, - content: Content + content: Content, + isReorderable: Bool = false, + contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil ) { self.id = id self.content = content + self.isReorderable = isReorderable + self.contextAction = contextAction } - public init( + convenience public init( id: AnyHashable, - title: String + title: String, + isReorderable: Bool = false, + contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil ) { - self.init(id: id, content: .text(title)) + self.init(id: id, content: .text(title), isReorderable: isReorderable, contextAction: contextAction) + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.content != rhs.content { + return false + } + if lhs.isReorderable != rhs.isReorderable { + return false + } + if (lhs.contextAction == nil) != (rhs.contextAction == nil) { + return false + } + return true } } public let context: AccountContext? public let colors: Colors + public let theme: PresentationTheme public let customLayout: CustomLayout? public let items: [Item] public let selectedId: AnyHashable? + public let reorderItem: ((AnyHashable, AnyHashable) -> Void)? public let setSelectedId: (AnyHashable) -> Void public let transitionFraction: CGFloat? public init( context: AccountContext? = nil, colors: Colors, + theme: PresentationTheme, customLayout: CustomLayout? = nil, items: [Item], selectedId: AnyHashable?, + reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil, setSelectedId: @escaping (AnyHashable) -> Void, transitionFraction: CGFloat? = nil ) { self.context = context self.colors = colors + self.theme = theme self.customLayout = customLayout self.items = items self.selectedId = selectedId + self.reorderItem = reorderItem self.setSelectedId = setSelectedId self.transitionFraction = transitionFraction } @@ -115,6 +147,9 @@ public final class TabSelectorComponent: Component { if lhs.colors != rhs.colors { return false } + if lhs.theme !== rhs.theme { + return false + } if lhs.customLayout != rhs.customLayout { return false } @@ -124,16 +159,188 @@ public final class TabSelectorComponent: Component { if lhs.selectedId != rhs.selectedId { return false } + if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) { + return false + } if lhs.transitionFraction != rhs.transitionFraction { return false } return true } - private final class VisibleItem { + final class VisibleItem: UIView { + let action: () -> Void + let contextAction: (ASDisplayNode, ContextGesture) -> Void + + let extractedContainerNode: ContextExtractedContentContainingNode + let containerNode: ContextControllerSourceNode + + let containerButton: UIView + var extractedBackgroundView: UIImageView? + let title = ComponentView() - init() { + var item: Item? + + var tapGesture: UITapGestureRecognizer? + var theme: PresentationTheme? + var size: CGSize? + var isReordering: Bool = false + + init(action: @escaping () -> Void, contextAction: @escaping (ASDisplayNode, ContextGesture) -> Void) { + self.action = action + self.contextAction = contextAction + + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + + self.containerButton = UIView() + + super.init(frame: CGRect()) + + self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + //self.containerButton.addSubview(self.iconContainer) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))) + self.tapGesture = tapGesture + self.containerButton.addGestureRecognizer(tapGesture) + tapGesture.isEnabled = false + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self else { + return + } + self.contextAction(self.extractedContainerNode, gesture) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let theme = self.theme, let size = self.size else { + return + } + + if isExtracted { + let extractedBackgroundView: UIImageView + if let current = self.extractedBackgroundView { + extractedBackgroundView = current + } else { + extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: size.height, color: theme.contextMenu.backgroundColor)) + self.extractedBackgroundView = extractedBackgroundView + self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) + extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 0.0, dy: 0.0) + extractedBackgroundView.alpha = 0.0 + } + transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) + } else if let extractedBackgroundView = self.extractedBackgroundView { + self.extractedBackgroundView = nil + let alphaTransition: ContainedViewLayoutTransition + if transition.isAnimated { + alphaTransition = .animated(duration: 0.18, curve: .easeInOut) + } else { + alphaTransition = .immediate + } + alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in + extractedBackgroundView?.removeFromSuperview() + }) + } + } + + self.containerNode.isGestureEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.action() + } + } + + private func updateIsShaking(animated: Bool) { + if self.isReordering { + if self.containerButton.layer.animation(forKey: "shaking_position") == nil { + let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.containerButton.layer.add(position, forKey: "shaking_position") + self.containerButton.layer.add(transform, forKey: "shaking_rotation") + } + } else if self.containerButton.layer.animation(forKey: "shaking_position") != nil { + if let presentationLayer = self.containerButton.layer.presentation() { + let transition: ComponentTransition = .easeInOut(duration: 0.1) + if presentationLayer.position != self.containerButton.layer.position { + transition.animatePosition(layer: self.containerButton.layer, from: CGPoint(x: presentationLayer.position.x - self.containerButton.layer.position.x, y: presentationLayer.position.y - self.containerButton.layer.position.y), to: CGPoint(), additive: true) + } + if !CATransform3DIsIdentity(presentationLayer.transform) { + transition.setTransform(layer: self.containerButton.layer, transform: CATransform3DIdentity) + } + } + + self.containerButton.layer.removeAnimation(forKey: "shaking_position") + self.containerButton.layer.removeAnimation(forKey: "shaking_rotation") + } + } + + func update(theme: PresentationTheme, size: CGSize, item: Item, isReordering: Bool, transition: ComponentTransition) { + self.theme = theme + self.size = size + self.isReordering = isReordering + self.item = item + + self.containerNode.isGestureEnabled = item.contextAction != nil && !isReordering + self.tapGesture?.isEnabled = !isReordering + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + + self.updateIsShaking(animated: !transition.animation.isImmediate) } } @@ -146,6 +353,10 @@ public final class TabSelectorComponent: Component { private var didInitiallyScroll = false + private var reorderRecognizer: ReorderGestureRecognizer? + private weak var reorderingItem: VisibleItem? + private var reorderingItemPosition: (initial: CGFloat, offset: CGFloat) = (0.0, 0.0) + override init(frame: CGRect) { self.selectionView = UIImageView() @@ -161,6 +372,53 @@ public final class TabSelectorComponent: Component { self.clipsToBounds = false self.addSubview(self.selectionView) + + let reorderRecognizer = ReorderGestureRecognizer( + shouldBegin: { [weak self] point in + guard let self, let component = self.component, component.reorderItem != nil else { + return (allowed: false, requiresLongPress: false, item: nil) + } + + var item: VisibleItem? + for (_, visibleItem) in self.visibleItems { + if visibleItem.bounds.contains(self.convert(point, to: visibleItem)) { + item = visibleItem + break + } + } + + if let item, let itemValue = item.item, itemValue.isReorderable { + return (allowed: true, requiresLongPress: false, item: item) + } else { + return (allowed: false, requiresLongPress: false, item: nil) + } + }, + willBegin: { point in + }, + began: { [weak self] item in + guard let self else { + return + } + self.setReorderingItem(item: item) + }, + ended: { [weak self] in + guard let self else { + return + } + self.setReorderingItem(item: nil) + }, + moved: { [weak self] distance in + guard let self else { + return + } + self.moveReorderingItem(distance: distance.x) + }, + isActiveUpdated: { _ in + } + ) + self.reorderRecognizer = reorderRecognizer + self.addGestureRecognizer(reorderRecognizer) + reorderRecognizer.isEnabled = false } required init?(coder: NSCoder) { @@ -174,12 +432,65 @@ public final class TabSelectorComponent: Component { return true } + private func setReorderingItem(item: VisibleItem?) { + self.reorderingItem = item + if let item { + self.reorderingItemPosition.initial = item.frame.minX + self.reorderingItemPosition.offset = 0.0 + } else { + self.reorderingItemPosition = (0.0, 0.0) + } + self.state?.updated(transition: .easeInOut(duration: 0.2)) + } + + private func moveReorderingItem(distance: CGFloat) { + guard let reorderingItem = self.reorderingItem else { + return + } + let previousPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5 + self.reorderingItemPosition.offset = distance + let updatedPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5 + + self.state?.updated(transition: .immediate) + + if let component = self.component, let reorderItem = component.reorderItem { + var currentId: AnyHashable? + var reorderToId: AnyHashable? + for (id, item) in self.visibleItems { + if item === reorderingItem { + currentId = id + continue + } + guard let targetItem = item.item else { + continue + } + if !targetItem.isReorderable { + continue + } + if reorderToId != nil { + continue + } + let itemCenter = item.center.x + if previousPosition < itemCenter && updatedPosition > itemCenter { + reorderToId = id + } else if previousPosition > itemCenter && updatedPosition < itemCenter { + reorderToId = id + } + } + if let currentId, let reorderToId { + reorderItem(currentId, reorderToId) + } + } + } + func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let selectionColorUpdated = component.colors.selection != self.component?.colors.selection self.component = component self.state = state + self.reorderRecognizer?.isEnabled = component.reorderItem != nil + let baseHeight: CGFloat = 28.0 var verticalInset: CGFloat = 0.0 @@ -231,7 +542,24 @@ public final class TabSelectorComponent: Component { if let current = self.visibleItems[item.id] { itemView = current } else { - itemView = VisibleItem() + let itemId = item.id + itemView = VisibleItem(action: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let item = component.items.first(where: { $0.id == itemId }) else { + return + } + component.setSelectedId(item.id) + }, contextAction: { [weak self] sourceNode, gesture in + guard let self, let component = self.component else { + return + } + guard let item = component.items.first(where: { $0.id == itemId }) else { + return + } + item.contextAction?(sourceNode, gesture) + }) self.visibleItems[item.id] = itemView itemTransition = itemTransition.withAnimation(.none) } @@ -261,32 +589,18 @@ public final class TabSelectorComponent: Component { let itemSize = itemView.title.update( transition: .immediate, - component: AnyComponent(PlainButtonComponent( - content: AnyComponent(ItemComponent( - context: component.context, - content: item.content, - font: itemFont, - color: component.colors.foreground, - selectedColor: component.colors.selection, - selectionFraction: useSelectionFraction ? selectionFraction : 0.0 - )), - effectAlignment: .center, - minSize: nil, - action: { [weak self, weak itemView] in - guard let self, let component = self.component else { - return - } - component.setSelectedId(itemId) - - if let view = itemView?.title.view, allowScroll && self.contentSize.width > self.bounds.width { - self.scrollRectToVisible(view.frame.insetBy(dx: -64.0, dy: 0.0), animated: true) - } - }, - animateScale: !isLineSelection + component: AnyComponent(ItemComponent( + context: component.context, + content: item.content, + font: itemFont, + color: component.colors.foreground, + selectedColor: component.colors.selection, + selectionFraction: useSelectionFraction ? selectionFraction : 0.0 )), environment: {}, containerSize: CGSize(width: 200.0, height: 100.0) ) + innerContentWidth += itemSize.width itemViews[item.id] = (itemView, itemSize, itemTransition) index += 1 @@ -302,6 +616,7 @@ public final class TabSelectorComponent: Component { var previousBackgroundRect: CGRect? var selectedBackgroundRect: CGRect? var nextBackgroundRect: CGRect? + var selectedItemIsReordering = false for item in component.items { guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else { @@ -310,10 +625,18 @@ public final class TabSelectorComponent: Component { if contentWidth > spacing { contentWidth += spacing } - let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) - let itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight)) + let baseItemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) + var itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight)) + let itemTitleFrame = CGRect(origin: CGPoint(x: baseItemTitleFrame.minX - itemBackgroundRect.minX, y: baseItemTitleFrame.minY - itemBackgroundRect.minY), size: baseItemTitleFrame.size) contentWidth = itemBackgroundRect.maxX + if self.reorderingItem === itemView { + itemBackgroundRect.origin.x = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + if item.id == component.selectedId { + selectedItemIsReordering = true + } + } + if item.id == component.selectedId { selectedBackgroundRect = itemBackgroundRect } @@ -323,14 +646,40 @@ public final class TabSelectorComponent: Component { nextBackgroundRect = itemBackgroundRect } + if itemView.superview == nil { + self.addSubview(itemView) + } + if let itemTitleView = itemView.title.view { if itemTitleView.superview == nil { itemTitleView.layer.anchorPoint = CGPoint() - self.addSubview(itemTitleView) + itemTitleView.isUserInteractionEnabled = false + itemView.containerButton.addSubview(itemTitleView) } - itemTransition.setPosition(view: itemTitleView, position: itemTitleFrame.origin) + + itemTransition.setPosition(view: itemView, position: itemBackgroundRect.center) + itemTransition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemBackgroundRect.size)) + + if self.reorderingItem === itemView { + itemTransition.setTransform(view: itemView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0)) + } else { + itemTransition.setTransform(view: itemView, transform: CATransform3DIdentity) + } + + itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition) + + itemTransition.setPosition(view: itemTitleView, position: CGPoint(x: itemTitleFrame.minX, y: itemTitleFrame.minY)) itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size)) - itemTransition.setAlpha(view: itemTitleView, alpha: item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4) + + var itemAlpha: CGFloat = item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4 + if component.reorderItem != nil && !item.isReorderable { + itemAlpha *= 0.5 + itemView.isUserInteractionEnabled = false + } else { + itemView.isUserInteractionEnabled = true + } + + itemTransition.setAlpha(view: itemTitleView, alpha: itemAlpha) } } contentWidth += spacing @@ -339,7 +688,7 @@ public final class TabSelectorComponent: Component { for (id, itemView) in self.visibleItems { if !validIds.contains(id) { removeIds.append(id) - itemView.title.view?.removeFromSuperview() + itemView.removeFromSuperview() } } for id in removeIds { @@ -366,9 +715,17 @@ public final class TabSelectorComponent: Component { var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0) mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0 mappedSelectionFrame.size.height = 3.0 - transition.setFrame(view: self.selectionView, frame: mappedSelectionFrame) + transition.setPosition(view: self.selectionView, position: mappedSelectionFrame.center) + transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: mappedSelectionFrame.size)) + transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity) } else { - transition.setFrame(view: self.selectionView, frame: selectedBackgroundRect) + transition.setPosition(view: self.selectionView, position: selectedBackgroundRect.center) + transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: selectedBackgroundRect.size)) + if selectedItemIsReordering { + transition.setTransform(view: self.selectionView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0)) + } else { + transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity) + } } } else { self.selectionView.alpha = 0.0 @@ -528,3 +885,197 @@ private final class ItemComponent: CombinedComponent { } } } + +private final class ReorderGestureRecognizer: UIGestureRecognizer { + private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?) + private let willBegin: (CGPoint) -> Void + private let began: (TabSelectorComponent.VisibleItem) -> Void + private let ended: () -> Void + private let moved: (CGPoint) -> Void + private let isActiveUpdated: (Bool) -> Void + + private var initialLocation: CGPoint? + private var longTapTimer: Foundation.Timer? + private var longPressTimer: Foundation.Timer? + + private var itemView: TabSelectorComponent.VisibleItem? + + public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (TabSelectorComponent.VisibleItem) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { + self.shouldBegin = shouldBegin + self.willBegin = willBegin + self.began = began + self.ended = ended + self.moved = moved + self.isActiveUpdated = isActiveUpdated + + super.init(target: nil, action: nil) + } + + deinit { + self.longTapTimer?.invalidate() + self.longPressTimer?.invalidate() + } + + private func startLongTapTimer() { + self.longTapTimer?.invalidate() + let longTapTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] _ in + self?.longTapTimerFired() + }) + self.longTapTimer = longTapTimer + } + + private func stopLongTapTimer() { + self.itemView = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + } + + private func startLongPressTimer() { + self.longPressTimer?.invalidate() + let longPressTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.6, repeats: false, block: { [weak self] _ in + self?.longPressTimerFired() + }) + self.longPressTimer = longPressTimer + } + + private func stopLongPressTimer() { + self.itemView = nil + self.longPressTimer?.invalidate() + self.longPressTimer = nil + } + + override public func reset() { + super.reset() + + self.itemView = nil + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + + self.isActiveUpdated(false) + } + + private func longTapTimerFired() { + guard let location = self.initialLocation else { + return + } + + self.longTapTimer?.invalidate() + self.longTapTimer = nil + + self.willBegin(location) + } + + private func longPressTimerFired() { + guard let _ = self.initialLocation else { + return + } + + self.isActiveUpdated(true) + self.state = .began + self.longPressTimer?.invalidate() + self.longPressTimer = nil + self.longTapTimer?.invalidate() + self.longTapTimer = nil + if let itemView = self.itemView { + self.began(itemView) + } + self.isActiveUpdated(true) + } + + override public func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if self.numberOfTouches > 1 { + self.isActiveUpdated(false) + self.state = .failed + self.ended() + return + } + + if self.state == .possible { + if let location = touches.first?.location(in: self.view) { + let (allowed, requiresLongPress, itemView) = self.shouldBegin(location) + if allowed { + self.isActiveUpdated(true) + + self.itemView = itemView + self.initialLocation = location + if requiresLongPress { + self.startLongTapTimer() + self.startLongPressTimer() + } else { + self.state = .began + if let itemView = self.itemView { + self.began(itemView) + } + } + } else { + self.isActiveUpdated(false) + self.state = .failed + } + } else { + self.isActiveUpdated(false) + self.state = .failed + } + } + } + + override public func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.stopLongPressTimer() + self.isActiveUpdated(false) + self.state = .failed + } + if self.state == .began || self.state == .changed { + self.isActiveUpdated(false) + self.ended() + self.state = .failed + } + } + + override public func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + self.initialLocation = nil + + self.stopLongTapTimer() + if self.longPressTimer != nil { + self.isActiveUpdated(false) + self.stopLongPressTimer() + self.state = .failed + } + if self.state == .began || self.state == .changed { + self.isActiveUpdated(false) + self.ended() + self.state = .failed + } + } + + override public func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { + self.state = .changed + let offset = CGPoint(x: location.x - initialLocation.x, y: 0.0) + self.moved(offset) + } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { + let touchLocation = touch.location(in: self.view) + let dX = touchLocation.x - initialTapLocation.x + + if dX > 3.0 { + self.stopLongTapTimer() + self.stopLongPressTimer() + self.initialLocation = nil + self.isActiveUpdated(false) + self.state = .failed + } + } + } +} + diff --git a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift index 878ab6ef23..dcd58dfa09 100644 --- a/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift +++ b/submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift @@ -280,7 +280,7 @@ public final class TextLoadingEffectView: UIView { maskBorderShapeLayer = SimpleShapeLayer() maskBorderShapeLayer.fillColor = nil maskBorderShapeLayer.strokeColor = UIColor.white.cgColor - maskBorderShapeLayer.lineWidth = 2.0 + maskBorderShapeLayer.lineWidth = 1.0 self.maskBorderShapeLayer = maskBorderShapeLayer }