diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index f5bef0abcc..0484642b5d 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -298,6 +298,7 @@ public enum ResolvedUrl { case startAttach(peerId: PeerId, payload: String?, choose: ResolvedBotChoosePeerTypes?) case invoice(slug: String, invoice: TelegramMediaInvoice?) case premiumOffer(reference: String?) + case chatFolder(slug: String) } public enum NavigateToChatKeepStack { diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 05e9622f3e..9a2e7df180 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -43,6 +43,7 @@ import PeerInfoUI import ComponentDisplayAdapters import ChatListHeaderComponent import ChatListTitleView +import InviteLinksUI private func fixListNodeScrolling(_ listNode: ListView, searchNode: NavigationBarSearchContentNode) -> Bool { if listNode.scroller.isDragging { @@ -1539,6 +1540,28 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController }) }))) } + + //TODO:localize + + for filter in filters { + if filter.id == filterId, case let .filter(_, _, _, data) = filter { + if !data.includePeers.peers.isEmpty { + items.append(.action(ContextMenuActionItem(text: "Share", textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Share"), color: theme.contextMenu.primaryColor) + }, action: { c, f in + c.dismiss(completion: { + guard let strongSelf = self else { + return + } + strongSelf.shareFolder(filterId: filterId, data: data) + }) + }))) + } + + break + } + } + break } } @@ -2676,6 +2699,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } + private func shareFolder(filterId: Int32, data: ChatListFilterData) { + self.push(folderInviteLinkListController(context: self.context, filterId: filterId, allPeerIds: data.includePeers.peers, currentInvitation: nil, linkUpdated: { _ in + })) + } + private func askForFilterRemoval(id: Int32) { let actionSheet = ActionSheetController(presentationData: self.presentationData) diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift index bf907a282f..d78e95e6a1 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift @@ -188,7 +188,7 @@ private enum ChatListFilterPresetEntrySortId: Comparable { case let .inviteLink(rhsIndex): return lhsIndex < rhsIndex case .inviteLinkFooter: - return false + return true } case .inviteLinkFooter: switch rhs { @@ -1059,8 +1059,8 @@ func chatListFilterPresetController(context: AccountContext, currentPreset: Chat let sharedLinks = Promise<[ExportedChatFolderLink]?>(nil) if let currentPreset { - sharedLinks.set(context.engine.peers.getExportedChatLinks(id: currentPreset.id) - |> map(Optional.init)) + sharedLinks.set(Signal<[ExportedChatFolderLink]?, NoError>.single(nil) |> then(context.engine.peers.getExportedChatFolderLinks(id: currentPreset.id) + |> map(Optional.init))) } let currentPeers = Atomic<[PeerId: EngineRenderedPeer]>(value: [:]) @@ -1266,20 +1266,43 @@ func chatListFilterPresetController(context: AccountContext, currentPreset: Chat }, createLink: { if let currentPreset, let data = currentPreset.data, !data.includePeers.peers.isEmpty { - pushControllerImpl?(folderInviteLinkListController(context: context, filterId: currentPreset.id, allPeerIds: data.includePeers.peers, currentInvitation: nil)) - - /*if data.isShared { - - } else { - let _ = (context.engine.peers.exportChatFolder(filterId: currentPreset.id, title: "Link", peerIds: data.includePeers.peers) - |> deliverOnMainQueue).start(completed: { - dismissImpl?() + pushControllerImpl?(folderInviteLinkListController(context: context, filterId: currentPreset.id, allPeerIds: data.includePeers.peers, currentInvitation: nil, linkUpdated: { updatedLink in + let _ = (sharedLinks.get() |> take(1) |> deliverOnMainQueue).start(next: { links in + guard var links else { + return + } + + if let updatedLink { + links.insert(updatedLink, at: 0) + sharedLinks.set(.single(links)) + } }) - }*/ + })) } }, openLink: { link in if let currentPreset, let data = currentPreset.data { - pushControllerImpl?(folderInviteLinkListController(context: context, filterId: currentPreset.id, allPeerIds: data.includePeers.peers, currentInvitation: link)) + pushControllerImpl?(folderInviteLinkListController(context: context, filterId: currentPreset.id, allPeerIds: data.includePeers.peers, currentInvitation: link, linkUpdated: { updatedLink in + if updatedLink != link { + let _ = (sharedLinks.get() |> take(1) |> deliverOnMainQueue).start(next: { links in + guard var links else { + return + } + + if let updatedLink { + if let index = links.firstIndex(where: { $0 == link }) { + links.remove(at: index) + } + links.insert(updatedLink, at: 0) + sharedLinks.set(.single(links)) + } else { + if let index = links.firstIndex(where: { $0 == link }) { + links.remove(at: index) + sharedLinks.set(.single(links)) + } + } + }) + } + })) } } ) diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index 029afd556d..2776218960 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -6,16 +6,17 @@ import LegacyComponents import TelegramPresentationData public struct CheckNodeTheme { - public let backgroundColor: UIColor - public let strokeColor: UIColor - public let borderColor: UIColor - public let overlayBorder: Bool - public let hasInset: Bool - public let hasShadow: Bool - public let filledBorder: Bool - public let borderWidth: CGFloat? + public var backgroundColor: UIColor + public var strokeColor: UIColor + public var borderColor: UIColor + public var overlayBorder: Bool + public var hasInset: Bool + public var hasShadow: Bool + public var filledBorder: Bool + public var borderWidth: CGFloat? + public var isDottedBorder: Bool - public init(backgroundColor: UIColor, strokeColor: UIColor, borderColor: UIColor, overlayBorder: Bool, hasInset: Bool, hasShadow: Bool, filledBorder: Bool = false, borderWidth: CGFloat? = nil) { + public init(backgroundColor: UIColor, strokeColor: UIColor, borderColor: UIColor, overlayBorder: Bool, hasInset: Bool, hasShadow: Bool, filledBorder: Bool = false, borderWidth: CGFloat? = nil, isDottedBorder: Bool = false) { self.backgroundColor = backgroundColor self.strokeColor = strokeColor self.borderColor = borderColor @@ -24,6 +25,7 @@ public struct CheckNodeTheme { self.hasShadow = hasShadow self.filledBorder = filledBorder self.borderWidth = borderWidth + self.isDottedBorder = isDottedBorder } } @@ -168,6 +170,9 @@ public class CheckNode: ASDisplayNode { let fillProgress = parameters.animatingOut ? 1.0 : min(1.0, parameters.animationProgress * 1.35) context.setStrokeColor(parameters.theme.borderColor.cgColor) + if parameters.theme.isDottedBorder { + context.setLineDash(phase: 0.0, lengths: [4.0, 4.0]) + } context.setLineWidth(borderWidth) let maybeScaleOut = { diff --git a/submodules/InviteLinksUI/Sources/FolderInviteLinkListController.swift b/submodules/InviteLinksUI/Sources/FolderInviteLinkListController.swift index 1136d97af8..cfbea0bd96 100644 --- a/submodules/InviteLinksUI/Sources/FolderInviteLinkListController.swift +++ b/submodules/InviteLinksUI/Sources/FolderInviteLinkListController.swift @@ -27,8 +27,8 @@ private final class FolderInviteLinkListControllerArguments { let shareMainLink: (String) -> Void let openMainLink: (String) -> Void let copyLink: (String) -> Void - let mainLinkContextAction: (String?, ASDisplayNode, ContextGesture?) -> Void - let peerAction: (EnginePeer) -> Void + let mainLinkContextAction: (ExportedChatFolderLink?, ASDisplayNode, ContextGesture?) -> Void + let peerAction: (EnginePeer, Bool) -> Void let generateLink: () -> Void init( @@ -36,8 +36,8 @@ private final class FolderInviteLinkListControllerArguments { shareMainLink: @escaping (String) -> Void, openMainLink: @escaping (String) -> Void, copyLink: @escaping (String) -> Void, - mainLinkContextAction: @escaping (String?, ASDisplayNode, ContextGesture?) -> Void, - peerAction: @escaping (EnginePeer) -> Void, + mainLinkContextAction: @escaping (ExportedChatFolderLink?, ASDisplayNode, ContextGesture?) -> Void, + peerAction: @escaping (EnginePeer, Bool) -> Void, generateLink: @escaping () -> Void ) { self.context = context @@ -68,7 +68,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry { case mainLink(link: ExportedChatFolderLink?, isGenerating: Bool) case peersHeader(String) - case peer(index: Int, peer: EnginePeer, isSelected: Bool) + case peer(index: Int, peer: EnginePeer, isSelected: Bool, isEnabled: Bool) case peersInfo(String) var section: ItemListSectionId { @@ -94,7 +94,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry { return .index(4) case .peersInfo: return .index(5) - case let .peer(_, peer, _): + case let .peer(_, peer, _, _): return .peer(peer.id) } } @@ -109,7 +109,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry { return 2 case .peersHeader: return 4 - case let .peer(index, _, _): + case let .peer(index, _, _, _): return 10 + index case .peersInfo: return 1000 @@ -148,8 +148,8 @@ private enum InviteLinksListEntry: ItemListNodeEntry { } else { return false } - case let .peer(index, peer, isSelected): - if case .peer(index, peer, isSelected) = rhs { + case let .peer(index, peer, isSelected, isEnabled): + if case .peer(index, peer, isSelected, isEnabled) = rhs { return true } else { return false @@ -180,7 +180,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry { arguments.generateLink() } }, contextAction: { node, gesture in - arguments.mainLinkContextAction(link?.link, node, gesture) + arguments.mainLinkContextAction(link, node, gesture) }, viewAction: { if let link { arguments.openMainLink(link.link) @@ -190,7 +190,8 @@ private enum InviteLinksListEntry: ItemListNodeEntry { return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .peersInfo(text): return ItemListTextItem(presentationData: presentationData, text: .markdown(text), sectionId: self.section) - case let .peer(_, peer, isSelected): + case let .peer(_, peer, isSelected, isEnabled): + //TODO:localize return ItemListPeerItem( presentationData: presentationData, dateTimeFormat: PresentationDateTimeFormat(), @@ -198,15 +199,15 @@ private enum InviteLinksListEntry: ItemListNodeEntry { context: arguments.context, peer: peer, presence: nil, - text: .none, + text: .text(isEnabled ? "you can invite others here" : "you can't invite others here", .secondary), label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), - switchValue: ItemListPeerItemSwitch(value: isSelected, style: .leftCheck), + switchValue: ItemListPeerItemSwitch(value: isSelected, style: .leftCheck, isEnabled: isEnabled), enabled: true, selectable: true, sectionId: self.section, action: { - arguments.peerAction(peer) + arguments.peerAction(peer, isEnabled) }, setPeerIdWithRevealedOptions: { _, _ in }, @@ -217,6 +218,21 @@ private enum InviteLinksListEntry: ItemListNodeEntry { } } +private func canShareLinkToPeer(peer: EnginePeer) -> Bool { + var isEnabled = false + switch peer { + case let .channel(channel): + if channel.hasPermission(.inviteMembers) { + isEnabled = true + } else if channel.username != nil { + isEnabled = true + } + default: + break + } + return isEnabled +} + private func folderInviteLinkListControllerEntries( presentationData: PresentationData, state: FolderInviteLinkListControllerState, @@ -224,16 +240,38 @@ private func folderInviteLinkListControllerEntries( ) -> [InviteLinksListEntry] { var entries: [InviteLinksListEntry] = [] - entries.append(.header("Anyone with this link can add Gaming Club folder and the 2 chats selected below.")) + let chatCountString: String + let peersHeaderString: String + if state.selectedPeerIds.isEmpty { + chatCountString = "Anyone with this link can add Gaming Club folder and the chats selected below." + peersHeaderString = "CHATS" + } else if state.selectedPeerIds.count == 1 { + chatCountString = "Anyone with this link can add Gaming Club folder and the 1 chat selected below." + peersHeaderString = "1 CHAT SELECTED" + } else { + chatCountString = "Anyone with this link can add Gaming Club folder and the \(state.selectedPeerIds.count) chats selected below." + peersHeaderString = "\(state.selectedPeerIds.count) CHATS SELECTED" + } + entries.append(.header(chatCountString)) //TODO:localize entries.append(.mainLinkHeader("INVITE LINK")) entries.append(.mainLink(link: state.currentLink, isGenerating: state.generatingLink)) - entries.append(.peersHeader("\(allPeers.count) CHATS SELECTED")) - for peer in allPeers { - entries.append(.peer(index: entries.count, peer: peer, isSelected: state.selectedPeerIds.contains(peer.id))) + entries.append(.peersHeader(peersHeaderString)) + + var sortedPeers: [EnginePeer] = [] + for peer in allPeers.filter({ canShareLinkToPeer(peer: $0) }) { + sortedPeers.append(peer) + } + for peer in allPeers.filter({ !canShareLinkToPeer(peer: $0) }) { + sortedPeers.append(peer) + } + + for peer in sortedPeers { + let isEnabled = canShareLinkToPeer(peer: peer) + entries.append(.peer(index: entries.count, peer: peer, isSelected: state.selectedPeerIds.contains(peer.id), isEnabled: isEnabled)) } return entries @@ -245,11 +283,12 @@ private struct FolderInviteLinkListControllerState: Equatable { var generatingLink: Bool = false } -public func folderInviteLinkListController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, filterId: Int32, allPeerIds: [PeerId], currentInvitation: ExportedChatFolderLink?) -> ViewController { +public func folderInviteLinkListController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, filterId: Int32, allPeerIds: [PeerId], currentInvitation: ExportedChatFolderLink?, linkUpdated: @escaping (ExportedChatFolderLink?) -> Void) -> ViewController { var pushControllerImpl: ((ViewController) -> Void)? let _ = pushControllerImpl var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? var presentInGlobalOverlayImpl: ((ViewController) -> Void)? + var dismissImpl: (() -> Void)? var dismissTooltipsImpl: (() -> Void)? @@ -257,9 +296,6 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese var initialState = FolderInviteLinkListControllerState() initialState.currentLink = currentInvitation - for peerId in allPeerIds { - initialState.selectedPeerIds.insert(peerId) - } let statePromise = ValuePromise(initialState, ignoreRepeated: true) let stateValue = Atomic(value: initialState) let updateState: ((FolderInviteLinkListControllerState) -> FolderInviteLinkListControllerState) -> Void = { f in @@ -275,6 +311,8 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese var getControllerImpl: (() -> ViewController?)? + var displayTooltipImpl: ((UndoOverlayContent) -> Void)? + let arguments = FolderInviteLinkListControllerArguments(context: context, shareMainLink: { inviteLink in let shareController = ShareController(context: context, subject: .url(inviteLink), updatedPresentationData: updatedPresentationData) shareController.completed = { peerIds in @@ -338,7 +376,7 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese dismissTooltipsImpl?() - UIPasteboard.general.string = invite + UIPasteboard.general.string = invite.link let presentationData = context.sharedContext.currentPresentationData.with { $0 } presentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.InviteLink_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) @@ -349,22 +387,55 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese }, action: { _, f in f(.dismissWithoutContent) - //presentControllerImpl?(QrCodeScreen(context: context, updatedPresentationData: updatedPresentationData, subject: .invite(invite: invite, isGroup: isGroup)), nil) + presentControllerImpl?(QrCodeScreen(context: context, updatedPresentationData: updatedPresentationData, subject: .chatFolder(slug: invite.slug)), nil) + }))) + + items.append(.action(ContextMenuActionItem(text: presentationData.strings.InviteLink_ContextRevoke, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { _, f in + f(.dismissWithoutContent) + + let _ = (context.engine.peers.editChatFolderLink(filterId: filterId, link: invite, title: nil, revoke: true) + |> deliverOnMainQueue).start(completed: { + let _ = (context.engine.peers.revokeChatFolderLink(filterId: filterId, link: invite) + |> deliverOnMainQueue).start(completed: { + linkUpdated(nil) + dismissImpl?() + }) + }) }))) let contextController = ContextController(account: context.account, presentationData: presentationData, source: .reference(InviteLinkContextReferenceContentSource(controller: controller, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) presentInGlobalOverlayImpl?(contextController) - }, peerAction: { peer in - updateState { state in - var state = state - - if state.selectedPeerIds.contains(peer.id) { - state.selectedPeerIds.remove(peer.id) - } else { - state.selectedPeerIds.insert(peer.id) + }, peerAction: { peer, isEnabled in + let state = stateValue.with({ $0 }) + if state.currentLink != nil { + return + } + + if isEnabled { + updateState { state in + var state = state + + if state.selectedPeerIds.contains(peer.id) { + state.selectedPeerIds.remove(peer.id) + } else { + state.selectedPeerIds.insert(peer.id) + } + + return state } - - return state + } else { + //TODO:localized + var text = "You can't invite others here" + switch peer { + case .channel: + text = "You don't have the admin rights to share invite links to this group chat." + default: + break + } + dismissTooltipsImpl?() + displayTooltipImpl?(.peers(context: context, peers: [peer], title: nil, text: text, customUndoText: nil)) } }, generateLink: { let currentState = stateValue.with({ $0 }) @@ -377,8 +448,10 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese return state } - actionsDisposable.add((context.engine.peers.exportChatFolder(filterId: filterId, title: "Link", peerIds: Array(currentState.selectedPeerIds)) + actionsDisposable.add((context.engine.peers.exportChatFolder(filterId: filterId, title: "", peerIds: Array(currentState.selectedPeerIds)) |> deliverOnMainQueue).start(next: { result in + linkUpdated(result) + updateState { state in var state = state @@ -392,10 +465,48 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese } }) + var combinedPeerIds: [EnginePeer.Id] = [] + if let currentInvitation { + for peerId in currentInvitation.peerIds { + if !combinedPeerIds.contains(peerId) { + combinedPeerIds.append(peerId) + } + } + } + for peerId in allPeerIds { + if !combinedPeerIds.contains(peerId) { + combinedPeerIds.append(peerId) + } + } + let allPeers = context.engine.data.subscribe( - EngineDataList(allPeerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + EngineDataList(combinedPeerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) ) + let _ = (allPeers + |> take(1) + |> deliverOnMainQueue).start(next: { peers in + updateState { state in + var state = state + + if let currentInvitation { + for peerId in currentInvitation.peerIds { + state.selectedPeerIds.insert(peerId) + } + } else { + for peerId in allPeerIds { + if let peer = peers.first(where: { $0?.id == peerId }), let peerValue = peer { + if canShareLinkToPeer(peer: peerValue) { + state.selectedPeerIds.insert(peerId) + } + } + } + } + + return state + } + }) + let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData let signal = combineLatest(queue: .mainQueue(), presentationData, @@ -410,7 +521,23 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese let title: ItemListControllerTitle title = .text("Share Folder") - let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: title, leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) + var doneButton: ItemListNavigationButton? + doneButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Done), style: .bold, enabled: true, action: { + /*let state = stateValue.with({ $0 }) + if let currentLink = state.currentLink { + updateState { state in + var state = state + state.isSaving = true + return state + } + actionsDisposable.add(context.engine.peers.editChatFolderLink(filterId: filterId, link: currentLink, title: nil, revoke: false)) + } else { + dismissImpl?() + }*/ + dismissImpl?() + }) + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: title, leftNavigationButton: nil, rightNavigationButton: doneButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: folderInviteLinkListControllerEntries( presentationData: presentationData, state: state, @@ -451,9 +578,18 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese controller.presentInGlobalOverlay(c) } } + dismissImpl = { [weak controller] in + controller?.dismiss() + } getControllerImpl = { [weak controller] in return controller } + displayTooltipImpl = { [weak controller] c in + if let controller = controller { + let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + controller.present(UndoOverlayController(presentationData: presentationData, content: c, elevatedLayout: false, action: { _ in return false }), in: .current) + } + } dismissTooltipsImpl = { [weak controller] in controller?.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { diff --git a/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkItem.swift b/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkItem.swift index f3d5d3ad0f..ad37543a60 100644 --- a/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkItem.swift +++ b/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkItem.swift @@ -519,6 +519,13 @@ public class ItemListFolderInviteLinkItemNode: ListViewItemNode, ItemListItemNod shimmerNode.updateAbsoluteRect(rect, within: size) } + if itemListHasRoundedBlockLayout(params) { + shimmerNode.clipsToBounds = true + shimmerNode.cornerRadius = 11.0 + } else { + shimmerNode.cornerRadius = 0.0 + } + let lineWidth: CGFloat = 180.0 let lineDiameter: CGFloat = 12.0 let titleFrame = strongSelf.invitedPeersNode.frame diff --git a/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkListItem.swift b/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkListItem.swift index 3ae4917d02..0547c76ede 100644 --- a/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkListItem.swift +++ b/submodules/InviteLinksUI/Sources/ItemListFolderInviteLinkListItem.swift @@ -275,8 +275,8 @@ public class ItemListFolderInviteLinkListItemNode: ListViewItemNode, ItemListIte let nextColor: ItemBackgroundColor let transitionFraction: CGFloat - color = .green - nextColor = .yellow + color = .blue + nextColor = .blue transitionFraction = 1.0 let topColor = color.colors.top diff --git a/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift b/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift index 1ce9cd8e6e..f8d1a08c53 100644 --- a/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift +++ b/submodules/ItemListPeerItem/Sources/ItemListPeerItem.swift @@ -253,10 +253,12 @@ public enum ItemListPeerItemLabel { public struct ItemListPeerItemSwitch { public var value: Bool public var style: ItemListPeerItemSwitchStyle + public var isEnabled: Bool - public init(value: Bool, style: ItemListPeerItemSwitchStyle) { + public init(value: Bool, style: ItemListPeerItemSwitchStyle, isEnabled: Bool = true) { self.value = value self.style = style + self.isEnabled = isEnabled } } @@ -1251,7 +1253,9 @@ public class ItemListPeerItemNode: ItemListRevealOptionsItemNode, ItemListItemNo if let current = strongSelf.leftCheckNode { leftCheckNode = current } else { - leftCheckNode = CheckNode(theme: CheckNodeTheme(theme: item.presentationData.theme, style: .plain)) + var checkTheme = CheckNodeTheme(theme: item.presentationData.theme, style: .plain) + checkTheme.isDottedBorder = !switchValue.isEnabled + leftCheckNode = CheckNode(theme: checkTheme) strongSelf.leftCheckNode = leftCheckNode strongSelf.avatarNode.supernode?.addSubnode(leftCheckNode) } diff --git a/submodules/QrCodeUI/Sources/QrCodeScreen.swift b/submodules/QrCodeUI/Sources/QrCodeScreen.swift index cd5e3546d4..dcf605ce57 100644 --- a/submodules/QrCodeUI/Sources/QrCodeScreen.swift +++ b/submodules/QrCodeUI/Sources/QrCodeScreen.swift @@ -38,6 +38,7 @@ public final class QrCodeScreen: ViewController { public enum Subject { case peer(peer: EnginePeer) case invite(invite: ExportedInvitation, isGroup: Bool) + case chatFolder(slug: String) var link: String { switch self { @@ -45,6 +46,8 @@ public final class QrCodeScreen: ViewController { return "https://t.me/\(peer.addressName ?? "")" case let .invite(invite, _): return invite.link ?? "" + case let .chatFolder(slug): + return "https://t.me/folder/\(slug)" } } @@ -54,6 +57,8 @@ public final class QrCodeScreen: ViewController { return "Q" case .invite: return "Q" + case .chatFolder: + return "Q" } } } diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index 2c703c1ebd..10da945042 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -238,7 +238,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[594758406] = { return Api.EncryptedMessage.parse_encryptedMessageService($0) } dict[179611673] = { return Api.ExportedChatInvite.parse_chatInviteExported($0) } dict[-317687113] = { return Api.ExportedChatInvite.parse_chatInvitePublicJoinRequests($0) } - dict[-1350894801] = { return Api.ExportedCommunityInvite.parse_exportedCommunityInvite($0) } + dict[-337788502] = { return Api.ExportedCommunityInvite.parse_exportedCommunityInvite($0) } dict[1103040667] = { return Api.ExportedContactToken.parse_exportedContactToken($0) } dict[1571494644] = { return Api.ExportedMessageLink.parse_exportedMessageLink($0) } dict[-207944868] = { return Api.FileHash.parse_fileHash($0) } @@ -998,7 +998,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-191450938] = { return Api.channels.SendAsPeers.parse_sendAsPeers($0) } dict[1805101290] = { return Api.communities.ExportedCommunityInvite.parse_exportedCommunityInvite($0) } dict[-2662489] = { return Api.communities.ExportedInvites.parse_exportedInvites($0) } - dict[408604768] = { return Api.community.CommunityInvite.parse_communityInvite($0) } + dict[988463765] = { return Api.community.CommunityInvite.parse_communityInvite($0) } dict[74184410] = { return Api.community.CommunityInvite.parse_communityInviteAlready($0) } dict[182326673] = { return Api.contacts.Blocked.parse_blocked($0) } dict[-513392236] = { return Api.contacts.Blocked.parse_blockedSlice($0) } diff --git a/submodules/TelegramApi/Sources/Api24.swift b/submodules/TelegramApi/Sources/Api24.swift index 41fcc1bd57..b9a765e778 100644 --- a/submodules/TelegramApi/Sources/Api24.swift +++ b/submodules/TelegramApi/Sources/Api24.swift @@ -958,15 +958,16 @@ public extension Api.communities { } public extension Api.community { enum CommunityInvite: TypeConstructorDescription { - case communityInvite(peers: [Api.Peer], chats: [Api.Chat], users: [Api.User]) + case communityInvite(title: String, peers: [Api.Peer], chats: [Api.Chat], users: [Api.User]) case communityInviteAlready(filterId: Int32, missingPeers: [Api.Peer], chats: [Api.Chat], users: [Api.User]) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .communityInvite(let peers, let chats, let users): + case .communityInvite(let title, let peers, let chats, let users): if boxed { - buffer.appendInt32(408604768) + buffer.appendInt32(988463765) } + serializeString(title, buffer: buffer, boxed: false) buffer.appendInt32(481674261) buffer.appendInt32(Int32(peers.count)) for item in peers { @@ -1009,31 +1010,34 @@ public extension Api.community { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .communityInvite(let peers, let chats, let users): - return ("communityInvite", [("peers", peers as Any), ("chats", chats as Any), ("users", users as Any)]) + case .communityInvite(let title, let peers, let chats, let users): + return ("communityInvite", [("title", title as Any), ("peers", peers as Any), ("chats", chats as Any), ("users", users as Any)]) case .communityInviteAlready(let filterId, let missingPeers, let chats, let users): return ("communityInviteAlready", [("filterId", filterId as Any), ("missingPeers", missingPeers as Any), ("chats", chats as Any), ("users", users as Any)]) } } public static func parse_communityInvite(_ reader: BufferReader) -> CommunityInvite? { - var _1: [Api.Peer]? + var _1: String? + _1 = parseString(reader) + var _2: [Api.Peer]? if let _ = reader.readInt32() { - _1 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Peer.self) + _2 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Peer.self) } - var _2: [Api.Chat]? + var _3: [Api.Chat]? if let _ = reader.readInt32() { - _2 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Chat.self) + _3 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Chat.self) } - var _3: [Api.User]? + var _4: [Api.User]? if let _ = reader.readInt32() { - _3 = Api.parseVector(reader, elementSignature: 0, elementType: Api.User.self) + _4 = Api.parseVector(reader, elementSignature: 0, elementType: Api.User.self) } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil - if _c1 && _c2 && _c3 { - return Api.community.CommunityInvite.communityInvite(peers: _1!, chats: _2!, users: _3!) + let _c4 = _4 != nil + if _c1 && _c2 && _c3 && _c4 { + return Api.community.CommunityInvite.communityInvite(title: _1!, peers: _2!, chats: _3!, users: _4!) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api5.swift b/submodules/TelegramApi/Sources/Api5.swift index 6631c3e671..7731114f90 100644 --- a/submodules/TelegramApi/Sources/Api5.swift +++ b/submodules/TelegramApi/Sources/Api5.swift @@ -1026,14 +1026,15 @@ public extension Api { } public extension Api { enum ExportedCommunityInvite: TypeConstructorDescription { - case exportedCommunityInvite(title: String, url: String, peers: [Api.Peer]) + case exportedCommunityInvite(flags: Int32, title: String, url: String, peers: [Api.Peer]) public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) { switch self { - case .exportedCommunityInvite(let title, let url, let peers): + case .exportedCommunityInvite(let flags, let title, let url, let peers): if boxed { - buffer.appendInt32(-1350894801) + buffer.appendInt32(-337788502) } + serializeInt32(flags, buffer: buffer, boxed: false) serializeString(title, buffer: buffer, boxed: false) serializeString(url, buffer: buffer, boxed: false) buffer.appendInt32(481674261) @@ -1047,25 +1048,28 @@ public extension Api { public func descriptionFields() -> (String, [(String, Any)]) { switch self { - case .exportedCommunityInvite(let title, let url, let peers): - return ("exportedCommunityInvite", [("title", title as Any), ("url", url as Any), ("peers", peers as Any)]) + case .exportedCommunityInvite(let flags, let title, let url, let peers): + return ("exportedCommunityInvite", [("flags", flags as Any), ("title", title as Any), ("url", url as Any), ("peers", peers as Any)]) } } public static func parse_exportedCommunityInvite(_ reader: BufferReader) -> ExportedCommunityInvite? { - var _1: String? - _1 = parseString(reader) + var _1: Int32? + _1 = reader.readInt32() var _2: String? _2 = parseString(reader) - var _3: [Api.Peer]? + var _3: String? + _3 = parseString(reader) + var _4: [Api.Peer]? if let _ = reader.readInt32() { - _3 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Peer.self) + _4 = Api.parseVector(reader, elementSignature: 0, elementType: Api.Peer.self) } let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil - if _c1 && _c2 && _c3 { - return Api.ExportedCommunityInvite.exportedCommunityInvite(title: _1!, url: _2!, peers: _3!) + let _c4 = _4 != nil + if _c1 && _c2 && _c3 && _c4 { + return Api.ExportedCommunityInvite.exportedCommunityInvite(flags: _1!, title: _2!, url: _3!, peers: _4!) } else { return nil diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/Communities.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/Communities.swift index e7f395fcd3..8a48d13169 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/Communities.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/Communities.swift @@ -15,15 +15,28 @@ public struct ExportedChatFolderLink: Equatable { public var title: String public var link: String public var peerIds: [EnginePeer.Id] + public var isRevoked: Bool public init( title: String, link: String, - peerIds: [EnginePeer.Id] + peerIds: [EnginePeer.Id], + isRevoked: Bool ) { self.title = title self.link = link self.peerIds = peerIds + self.isRevoked = isRevoked + } +} + +public extension ExportedChatFolderLink { + var slug: String { + var slug = self.link + if slug.hasPrefix("https://t.me/folder/") { + slug = String(slug[slug.index(slug.startIndex, offsetBy: "https://t.me/folder/".count)...]) + } + return slug } } @@ -55,11 +68,12 @@ func _internal_exportChatFolder(account: Account, filterId: Int32, title: String }) switch invite { - case let .exportedCommunityInvite(title, url, peers): + case let .exportedCommunityInvite(flags, title, url, peers): return .single(ExportedChatFolderLink( title: title, link: url, - peerIds: peers.map(\.peerId) + peerIds: peers.map(\.peerId), + isRevoked: (flags & (1 << 0)) != 0 )) } } @@ -70,7 +84,7 @@ func _internal_exportChatFolder(account: Account, filterId: Int32, title: String } } -func _internal_getExportedChatLinks(account: Account, id: Int32) -> Signal<[ExportedChatFolderLink], NoError> { +func _internal_getExportedChatFolderLinks(account: Account, id: Int32) -> Signal<[ExportedChatFolderLink], NoError> { return account.network.request(Api.functions.communities.getExportedInvites(community: .inputCommunityDialogFilter(filterId: id))) |> map(Optional.init) |> `catch` { _ -> Signal in @@ -105,8 +119,13 @@ func _internal_getExportedChatLinks(account: Account, id: Int32) -> Signal<[Expo var result: [ExportedChatFolderLink] = [] for invite in invites { switch invite { - case let .exportedCommunityInvite(title, url, peers): - result.append(ExportedChatFolderLink(title: title, link: url, peerIds: peers.map(\.peerId))) + case let .exportedCommunityInvite(flags, title, url, peers): + result.append(ExportedChatFolderLink( + title: title, + link: url, + peerIds: peers.map(\.peerId), + isRevoked: (flags & (1 << 0)) != 0 + )) } } @@ -115,3 +134,171 @@ func _internal_getExportedChatLinks(account: Account, id: Int32) -> Signal<[Expo } } } + +public enum EditChatFolderLinkError { + case generic +} + +func _internal_editChatFolderLink(account: Account, filterId: Int32, link: ExportedChatFolderLink, title: String?, revoke: Bool) -> Signal { + var flags: Int32 = 0 + if revoke { + flags |= 1 << 0 + } + if title != nil { + flags |= 1 << 1 + } + return account.network.request(Api.functions.communities.editExportedInvite(flags: flags, community: .inputCommunityDialogFilter(filterId: filterId), slug: link.slug, title: title)) + |> mapError { _ -> EditChatFolderLinkError in + return .generic + } + |> ignoreValues + +} + +public enum RevokeChatFolderLinkError { + case generic +} + +func _internal_revokeChatFolderLink(account: Account, filterId: Int32, link: ExportedChatFolderLink) -> Signal { + return account.network.request(Api.functions.communities.deleteExportedInvite(community: .inputCommunityDialogFilter(filterId: filterId), slug: link.slug)) + |> mapError { _ -> RevokeChatFolderLinkError in + return .generic + } + |> ignoreValues +} + +public enum CheckChatFolderLinkError { + case generic +} + +public final class ChatFolderLinkContents { + public let localFilterId: Int32? + public let title: String? + public let peers: [EnginePeer] + public let alreadyMemberPeerIds: Set + + public init( + localFilterId: Int32?, + title: String?, + peers: [EnginePeer], + alreadyMemberPeerIds: Set + ) { + self.localFilterId = localFilterId + self.title = title + self.peers = peers + self.alreadyMemberPeerIds = alreadyMemberPeerIds + } +} + +func _internal_checkChatFolderLink(account: Account, slug: String) -> Signal { + return account.network.request(Api.functions.communities.checkCommunityInvite(slug: slug)) + |> mapError { _ -> CheckChatFolderLinkError in + return .generic + } + |> mapToSignal { result -> Signal in + return account.postbox.transaction { transaction -> ChatFolderLinkContents in + switch result { + case let .communityInvite(title, peers, chats, users): + var allPeers: [Peer] = [] + var peerPresences: [PeerId: Api.User] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + allPeers.append(telegramUser) + peerPresences[telegramUser.id] = user + } + for chat in chats { + if let peer = parseTelegramGroupOrChannel(chat: chat) { + allPeers.append(peer) + } + } + + updatePeers(transaction: transaction, peers: allPeers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + var resultPeers: [EnginePeer] = [] + var alreadyMemberPeerIds = Set() + for peer in peers { + if let peerValue = transaction.getPeer(peer.peerId) { + resultPeers.append(EnginePeer(peerValue)) + + if transaction.getPeerChatListIndex(peer.peerId) != nil { + alreadyMemberPeerIds.insert(peer.peerId) + } + } + } + + return ChatFolderLinkContents(localFilterId: nil, title: title, peers: resultPeers, alreadyMemberPeerIds: alreadyMemberPeerIds) + case let .communityInviteAlready(filterId, missingPeers, chats, users): + var allPeers: [Peer] = [] + var peerPresences: [PeerId: Api.User] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + allPeers.append(telegramUser) + peerPresences[telegramUser.id] = user + } + for chat in chats { + if let peer = parseTelegramGroupOrChannel(chat: chat) { + allPeers.append(peer) + } + } + + updatePeers(transaction: transaction, peers: allPeers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + let currentFilters = _internal_currentChatListFilters(transaction: transaction) + var currentFilterTitle: String? + if let index = currentFilters.firstIndex(where: { $0.id == filterId }) { + switch currentFilters[index] { + case let .filter(_, title, _, _): + currentFilterTitle = title + default: + break + } + } + + var resultPeers: [EnginePeer] = [] + var alreadyMemberPeerIds = Set() + for peer in missingPeers { + if let peerValue = transaction.getPeer(peer.peerId) { + resultPeers.append(EnginePeer(peerValue)) + + if transaction.getPeerChatListIndex(peer.peerId) != nil { + alreadyMemberPeerIds.insert(peer.peerId) + } + } + } + + return ChatFolderLinkContents(localFilterId: filterId, title: currentFilterTitle, peers: resultPeers, alreadyMemberPeerIds: alreadyMemberPeerIds) + } + } + |> castError(CheckChatFolderLinkError.self) + } +} + +public enum JoinChatFolderLinkError { + case generic +} + +func _internal_joinChatFolderLink(account: Account, slug: String, peerIds: [EnginePeer.Id]) -> Signal { + return account.postbox.transaction { transaction -> [Api.InputPeer] in + return peerIds.compactMap(transaction.getPeer).compactMap(apiInputPeer) + } + |> castError(JoinChatFolderLinkError.self) + |> mapToSignal { inputPeers -> Signal in + return account.network.request(Api.functions.communities.joinCommunityInvite(slug: slug, peers: inputPeers)) + |> mapError { _ -> JoinChatFolderLinkError in + return .generic + } + |> mapToSignal { result -> Signal in + account.stateManager.addUpdates(result) + + return .complete() + } + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift index ff6052cfed..75d5d0908a 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift @@ -1030,8 +1030,24 @@ public extension TelegramEngine { return _internal_exportChatFolder(account: self.account, filterId: filterId, title: title, peerIds: peerIds) } - public func getExportedChatLinks(id: Int32) -> Signal<[ExportedChatFolderLink], NoError> { - return _internal_getExportedChatLinks(account: self.account, id: id) + public func getExportedChatFolderLinks(id: Int32) -> Signal<[ExportedChatFolderLink], NoError> { + return _internal_getExportedChatFolderLinks(account: self.account, id: id) + } + + public func editChatFolderLink(filterId: Int32, link: ExportedChatFolderLink, title: String?, revoke: Bool) -> Signal { + return _internal_editChatFolderLink(account: self.account, filterId: filterId, link: link, title: title, revoke: revoke) + } + + public func revokeChatFolderLink(filterId: Int32, link: ExportedChatFolderLink) -> Signal { + return _internal_revokeChatFolderLink(account: self.account, filterId: filterId, link: link) + } + + public func checkChatFolderLink(slug: String) -> Signal { + return _internal_checkChatFolderLink(account: self.account, slug: slug) + } + + public func joinChatFolderLink(slug: String, peerIds: [EnginePeer.Id]) -> Signal { + return _internal_joinChatFolderLink(account: self.account, slug: slug, peerIds: peerIds) } } } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index fc92fcbbac..2125a4eca0 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -358,6 +358,7 @@ swift_library( "//submodules/DrawingUI:DrawingUI", "//submodules/FeaturedStickersScreen:FeaturedStickersScreen", "//submodules/TelegramUI/Components/SendInviteLinkScreen", + "//submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen", "//submodules/TelegramUI/Components/SliderContextItem:SliderContextItem", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD new file mode 100644 index 0000000000..9965bd5061 --- /dev/null +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/BUILD @@ -0,0 +1,36 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatFolderLinkPreviewScreen", + module_name = "ChatFolderLinkPreviewScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/AppBundle", + "//submodules/TelegramStringFormatting", + "//submodules/PresentationDataUtils", + "//submodules/Components/SolidRoundedButtonComponent", + "//submodules/AvatarNode", + "//submodules/CheckNode", + "//submodules/Markdown", + "//submodules/UndoUI", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkHeaderComponent.swift b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkHeaderComponent.swift new file mode 100644 index 0000000000..6055b622c4 --- /dev/null +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkHeaderComponent.swift @@ -0,0 +1,187 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import AccountContext +import MultilineTextComponent +import TelegramPresentationData + +final class ChatFolderLinkHeaderComponent: Component { + let theme: PresentationTheme + let strings: PresentationStrings + let title: String + let badge: String? + + init( + theme: PresentationTheme, + strings: PresentationStrings, + title: String, + badge: String? + ) { + self.theme = theme + self.strings = strings + self.title = title + self.badge = badge + } + + static func ==(lhs: ChatFolderLinkHeaderComponent, rhs: ChatFolderLinkHeaderComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.badge != rhs.badge { + return false + } + return true + } + + final class View: UIView { + private let leftView = UIImageView() + private let rightView = UIImageView() + private let title = ComponentView() + private let separatorLayer = SimpleLayer() + + private var component: ChatFolderLinkHeaderComponent? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubview(self.leftView) + self.addSubview(self.rightView) + self.layer.addSublayer(self.separatorLayer) + + self.separatorLayer.cornerRadius = 2.0 + self.separatorLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ChatFolderLinkHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + self.component = component + + let height: CGFloat = 60.0 + let spacing: CGFloat = 16.0 + + if themeUpdated { + //TODO:localize + let leftString = NSAttributedString(string: "All Chats", font: Font.semibold(14.0), textColor: component.theme.list.freeTextColor) + let rightString = NSAttributedString(string: "Personal", font: Font.semibold(14.0), textColor: component.theme.list.freeTextColor) + + let leftStringBounds = leftString.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + let rightStringBounds = rightString.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + + self.leftView.image = generateImage(leftStringBounds.size.integralFloor, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + leftString.draw(in: leftStringBounds) + + var colors: [UIColor] = [] + var locations: [CGFloat] = [] + for i in 0 ... 8 { + let t: CGFloat = CGFloat(i) / CGFloat(8) + let a: CGFloat = t * t + colors.append(UIColor(white: 1.0, alpha: a)) + locations.append(t) + } + + if let image = generateGradientImage(size: CGSize(width: size.width * 0.8, height: 16.0), colors: colors, locations: locations, direction: .horizontal) { + image.draw(in: CGRect(origin: CGPoint(), size: CGSize(width: image.size.width, height: size.height)), blendMode: .destinationIn, alpha: 1.0) + } + + UIGraphicsPopContext() + }) + self.leftView.alpha = 0.5 + + self.rightView.image = generateImage(rightStringBounds.size.integralFloor, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + + rightString.draw(in: rightStringBounds) + + var colors: [UIColor] = [] + var locations: [CGFloat] = [] + for i in 0 ... 8 { + let t: CGFloat = CGFloat(i) / CGFloat(8) + let a: CGFloat = 1.0 - t * t + colors.append(UIColor(white: 1.0, alpha: a)) + locations.append(t) + } + + if let image = generateGradientImage(size: CGSize(width: size.width * 0.8, height: 16.0), colors: colors, locations: locations, direction: .horizontal) { + image.draw(in: CGRect(origin: CGPoint(x: size.width - image.size.width, y: 0.0), size: CGSize(width: image.size.width, height: size.height)), blendMode: .destinationIn, alpha: 1.0) + } + + UIGraphicsPopContext() + }) + self.rightView.alpha = 0.5 + + self.separatorLayer.backgroundColor = component.theme.list.itemAccentColor.cgColor + } + + var contentWidth: CGFloat = 0.0 + if let leftImage = self.leftView.image { + contentWidth += leftImage.size.width + } + contentWidth += spacing + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(Text(text: component.title, font: Font.semibold(17.0), color: component.theme.list.itemAccentColor)), + environment: {}, + containerSize: CGSize(width: 200.0, height: 100.0) + ) + contentWidth += titleSize.width + + contentWidth += spacing + if let rightImage = self.rightView.image { + contentWidth += rightImage.size.width + } + + var contentOriginX: CGFloat = 0.0 + + if let leftImage = self.leftView.image { + transition.setFrame(view: self.leftView, frame: CGRect(origin: CGPoint(x: contentOriginX, y: floor((height - leftImage.size.height) / 2.0) - 1.0), size: leftImage.size)) + contentOriginX += leftImage.size.width + } + contentOriginX += spacing + + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + let titleFrame = CGRect(origin: CGPoint(x: contentOriginX, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + transition.setFrame(view: titleView, frame: titleFrame) + + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + 9.0), size: CGSize(width: titleFrame.width, height: 3.0))) + } + contentOriginX += titleSize.width + contentOriginX += spacing + + if let rightImage = self.rightView.image { + transition.setFrame(view: self.rightView, frame: CGRect(origin: CGPoint(x: contentOriginX, y: floor((height - rightImage.size.height) / 2.0) - 1.0), size: rightImage.size)) + contentOriginX += rightImage.size.width + } + + return CGSize(width: contentWidth, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift new file mode 100644 index 0000000000..7caf63e270 --- /dev/null +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/ChatFolderLinkPreviewScreen.swift @@ -0,0 +1,721 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import Postbox +import SolidRoundedButtonComponent +import PresentationDataUtils +import Markdown +import UndoUI + +private final class ChatFolderLinkPreviewScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let slug: String + let linkContents: ChatFolderLinkContents? + + init( + context: AccountContext, + slug: String, + linkContents: ChatFolderLinkContents? + ) { + self.context = context + self.slug = slug + self.linkContents = linkContents + } + + static func ==(lhs: ChatFolderLinkPreviewScreenComponent, rhs: ChatFolderLinkPreviewScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.slug != rhs.slug { + return false + } + if lhs.linkContents !== rhs.linkContents { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var containerSize: CGSize + var containerInset: CGFloat + var bottomInset: CGFloat + var topInset: CGFloat + + init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat) { + self.containerSize = containerSize + self.containerInset = containerInset + self.bottomInset = bottomInset + self.topInset = topInset + } + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + } + + final class View: UIView, UIScrollViewDelegate { + private let dimView: UIView + private let backgroundLayer: SimpleLayer + private let navigationBarContainer: SparseContainerView + private let scrollView: ScrollView + private let scrollContentClippingView: SparseContainerView + private let scrollContentView: UIView + + private let topIcon = ComponentView() + + private let title = ComponentView() + private let leftButton = ComponentView() + private let descriptionText = ComponentView() + private let actionButton = ComponentView() + + private let listHeaderText = ComponentView() + private let itemContainerView: UIView + private var items: [AnyHashable: ComponentView] = [:] + + private var selectedItems = Set() + + private let bottomOverscrollLimit: CGFloat + + private var ignoreScrolling: Bool = false + + private var component: ChatFolderLinkPreviewScreenComponent? + private weak var state: EmptyComponentState? + private var environment: ViewControllerComponentContainer.Environment? + private var itemLayout: ItemLayout? + + private var topOffsetDistance: CGFloat? + + private var joinDisposable: Disposable? + + override init(frame: CGRect) { + self.bottomOverscrollLimit = 200.0 + + self.dimView = UIView() + + self.backgroundLayer = SimpleLayer() + self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + self.backgroundLayer.cornerRadius = 10.0 + + self.navigationBarContainer = SparseContainerView() + + self.scrollView = ScrollView() + + self.scrollContentClippingView = SparseContainerView() + self.scrollContentClippingView.clipsToBounds = true + + self.scrollContentView = UIView() + + self.itemContainerView = UIView() + self.itemContainerView.clipsToBounds = true + self.itemContainerView.layer.cornerRadius = 10.0 + + super.init(frame: frame) + + self.addSubview(self.dimView) + self.layer.addSublayer(self.backgroundLayer) + + self.addSubview(self.navigationBarContainer) + + self.scrollView.delaysContentTouches = true + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.alwaysBounceVertical = true + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + + self.addSubview(self.scrollContentClippingView) + self.scrollContentClippingView.addSubview(self.scrollView) + + self.scrollView.addSubview(self.scrollContentView) + + self.scrollContentView.addSubview(self.itemContainerView) + + self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.joinDisposable?.dispose() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard let itemLayout = self.itemLayout, let topOffsetDistance = self.topOffsetDistance else { + return + } + + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + topOffset = max(0.0, topOffset) + + if topOffset < topOffsetDistance { + targetContentOffset.pointee.y = scrollView.contentOffset.y + scrollView.setContentOffset(CGPoint(x: 0.0, y: itemLayout.topInset), animated: true) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.bounds.contains(point) { + return nil + } + if !self.backgroundLayer.frame.contains(point) { + return self.dimView + } + + if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { + return result + } + + let result = super.hitTest(point, with: event) + return result + } + + @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + guard let environment = self.environment, let controller = environment.controller() else { + return + } + controller.dismiss() + } + } + + private func updateScrolling(transition: Transition) { + guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { + return + } + var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset + topOffset = max(0.0, topOffset) + transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) + + transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) + + let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) + self.topOffsetDistance = topOffsetDistance + var topOffsetFraction = topOffset / topOffsetDistance + topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) + + let transitionFactor: CGFloat = 1.0 - topOffsetFraction + controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) + } + + func animateIn() { + self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + let animateOffset: CGFloat = self.backgroundLayer.frame.minY + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } + } + + func animateOut(completion: @escaping () -> Void) { + let animateOffset: CGFloat = self.backgroundLayer.frame.minY + + self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in + completion() + }) + self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + if let actionButtonView = self.actionButton.view { + actionButtonView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) + } + } + + func update(component: ChatFolderLinkPreviewScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let themeUpdated = self.environment?.theme !== environment.theme + + let resetScrolling = self.scrollView.bounds.width != availableSize.width + + let sideInset: CGFloat = 16.0 + + if self.component?.linkContents == nil, let linkContents = component.linkContents { + for peer in linkContents.peers { + self.selectedItems.insert(peer.id) + } + } + + self.component = component + self.state = state + self.environment = environment + + if themeUpdated { + self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.backgroundLayer.backgroundColor = environment.theme.list.blocksBackgroundColor.cgColor + self.itemContainerView.backgroundColor = environment.theme.list.itemBlocksBackgroundColor + } + + transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) + + var contentHeight: CGFloat = 0.0 + + let leftButtonSize = self.leftButton.update( + transition: transition, + component: AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)), + action: { [weak self] in + guard let self, let controller = self.environment?.controller() else { + return + } + controller.dismiss() + } + ).minSize(CGSize(width: 44.0, height: 56.0))), + environment: {}, + containerSize: CGSize(width: 120.0, height: 100.0) + ) + let leftButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: leftButtonSize) + if let leftButtonView = self.leftButton.view { + if leftButtonView.superview == nil { + self.navigationBarContainer.addSubview(leftButtonView) + } + transition.setFrame(view: leftButtonView, frame: leftButtonFrame) + } + + let titleString: String + if let linkContents = component.linkContents { + //TODO:localize + if linkContents.localFilterId != nil { + if self.selectedItems.count == 1 { + titleString = "Add \(self.selectedItems.count) chat" + } else { + titleString = "Add \(self.selectedItems.count) chats" + } + } else { + titleString = "Add Folder" + } + } else { + titleString = " " + } + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: 18.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.navigationBarContainer.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + + contentHeight += 44.0 + contentHeight += 14.0 + + let topIconSize = self.topIcon.update( + transition: transition, + component: AnyComponent(ChatFolderLinkHeaderComponent( + theme: environment.theme, + strings: environment.strings, + title: component.linkContents?.title ?? "Folder", + badge: nil + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset, height: 1000.0) + ) + let topIconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - topIconSize.width) * 0.5), y: contentHeight), size: topIconSize) + if let topIconView = self.topIcon.view { + if topIconView.superview == nil { + self.scrollContentView.addSubview(topIconView) + } + transition.setFrame(view: topIconView, frame: topIconFrame) + topIconView.isHidden = component.linkContents == nil + } + + contentHeight += topIconSize.height + contentHeight += 20.0 + + let text: String + if let linkContents = component.linkContents { + if linkContents.localFilterId == nil { + text = "Do you want to add a new chat folder\nand join its groups and channels?" + } else { + let chatCountString: String + if self.selectedItems.count == 1 { + chatCountString = "1 chat" + } else { + chatCountString = "\(self.selectedItems.count) chats" + } + if let title = linkContents.title { + text = "Do you want to add **\(chatCountString)** to your\nfolder **\(title)**?" + } else { + text = "Do you want to add **\(chatCountString)** chats to your\nfolder?" + } + } + } else { + text = " " + } + + let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor) + let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor) + + let descriptionTextSize = self.descriptionText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .markdown(text: text, attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + )), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 16.0 * 2.0, height: 1000.0) + ) + let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize) + if let descriptionTextView = self.descriptionText.view { + if descriptionTextView.superview == nil { + self.scrollContentView.addSubview(descriptionTextView) + } + transition.setFrame(view: descriptionTextView, frame: descriptionTextFrame) + } + + contentHeight += descriptionTextFrame.height + contentHeight += 39.0 + + var singleItemHeight: CGFloat = 0.0 + + var itemsHeight: CGFloat = 0.0 + var validIds: [AnyHashable] = [] + if let linkContents = component.linkContents { + for i in 0 ..< linkContents.peers.count { + let peer = linkContents.peers[i] + + for _ in 0 ..< 1 { + //let id: AnyHashable = AnyHashable("\(peer.id)_\(j)") + let id = AnyHashable(peer.id) + validIds.append(id) + + let item: ComponentView + var itemTransition = transition + if let current = self.items[id] { + item = current + } else { + itemTransition = .immediate + item = ComponentView() + self.items[id] = item + } + + let itemSize = item.update( + transition: itemTransition, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + sideInset: 0.0, + title: peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), + peer: peer, + subtitle: nil, + selectionState: .editing(isSelected: self.selectedItems.contains(peer.id), isTinted: linkContents.alreadyMemberPeerIds.contains(peer.id)), + hasNext: i != linkContents.peers.count - 1, + action: { [weak self] peer in + guard let self else { + return + } + if self.selectedItems.contains(peer.id) { + self.selectedItems.remove(peer.id) + } else { + self.selectedItems.insert(peer.id) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .easeInOut))) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemsHeight), size: itemSize) + + if let itemView = item.view { + if itemView.superview == nil { + self.itemContainerView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + } + + itemsHeight += itemSize.height + singleItemHeight = itemSize.height + } + } + } + + var removeIds: [AnyHashable] = [] + for (id, item) in self.items { + if !validIds.contains(id) { + removeIds.append(id) + item.view?.removeFromSuperview() + } + } + for id in removeIds { + self.items.removeValue(forKey: id) + } + + let listHeaderTitle: String + if self.selectedItems.count == 1 { + listHeaderTitle = "1 CHAT IN FOLDER TO JOIN" + } else { + listHeaderTitle = "\(self.selectedItems.count) CHATS IN FOLDER TO JOIN" + } + + let listHeaderBody = MarkdownAttributeSet(font: Font.with(size: 13.0, design: .regular, traits: [.monospacedNumbers]), textColor: environment.theme.list.freeTextColor) + + let listHeaderTextSize = self.listHeaderText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: listHeaderTitle, + attributes: MarkdownAttributes( + body: listHeaderBody, + bold: listHeaderBody, + link: listHeaderBody, + linkAttribute: { _ in nil } + ) + ) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0, height: 1000.0) + ) + if let listHeaderTextView = self.listHeaderText.view { + if listHeaderTextView.superview == nil { + listHeaderTextView.layer.anchorPoint = CGPoint() + self.scrollContentView.addSubview(listHeaderTextView) + } + let listHeaderTextFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: listHeaderTextSize) + transition.setPosition(view: listHeaderTextView, position: listHeaderTextFrame.origin) + listHeaderTextView.bounds = CGRect(origin: CGPoint(), size: listHeaderTextFrame.size) + listHeaderTextView.isHidden = component.linkContents == nil + } + contentHeight += listHeaderTextSize.height + contentHeight += 6.0 + + transition.setFrame(view: self.itemContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: itemsHeight))) + + var initialContentHeight = contentHeight + initialContentHeight += min(itemsHeight, floor(singleItemHeight * 2.5)) + + contentHeight += itemsHeight + contentHeight += 24.0 + initialContentHeight += 24.0 + + let actionButtonTitle: String + if let linkContents = component.linkContents { + if linkContents.localFilterId != nil { + actionButtonTitle = "Join Chats" + } else { + actionButtonTitle = "Add Folder" + } + } else { + actionButtonTitle = " " + } + + let actionButtonSize = self.actionButton.update( + transition: transition, + component: AnyComponent(SolidRoundedButtonComponent( + title: actionButtonTitle, + badge: (self.selectedItems.isEmpty) ? nil : "\(self.selectedItems.count)", + theme: SolidRoundedButtonComponent.Theme(theme: environment.theme), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 11.0, + gloss: false, + isEnabled: !self.selectedItems.isEmpty, + animationName: nil, + iconPosition: .right, + iconSpacing: 4.0, + isLoading: component.linkContents == nil, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + + if let _ = component.linkContents { + if self.joinDisposable == nil, !self.selectedItems.isEmpty { + self.joinDisposable = (component.context.engine.peers.joinChatFolderLink(slug: component.slug, peerIds: Array(self.selectedItems)) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let controller = self.environment?.controller() else { + return + } + controller.dismiss() + }) + } + } + + /*if self.selectedItems.isEmpty { + controller.dismiss() + } else if let link = component.link { + let selectedPeers = component.peers.filter { self.selectedItems.contains($0.id) } + + let _ = enqueueMessagesToMultiplePeers(account: component.context.account, peerIds: Array(self.selectedItems), threadIds: [:], messages: [.message(text: link, attributes: [], inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start() + let text: String + if selectedPeers.count == 1 { + text = environment.strings.Conversation_ShareLinkTooltip_Chat_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string + } else if selectedPeers.count == 2 { + text = environment.strings.Conversation_ShareLinkTooltip_TwoChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), selectedPeers[1].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: "")).string + } else { + text = environment.strings.Conversation_ShareLinkTooltip_ManyChats_One(selectedPeers[0].displayTitle(strings: environment.strings, displayOrder: .firstLast).replacingOccurrences(of: "*", with: ""), "\(selectedPeers.count - 1)").string + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: false, text: text), elevatedLayout: false, action: { _ in return false }), in: .window(.root)) + + controller.dismiss() + } else { + controller.dismiss() + }*/ + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + let bottomPanelHeight = 14.0 + environment.safeInsets.bottom + actionButtonSize.height + let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize) + if let actionButtonView = self.actionButton.view { + if actionButtonView.superview == nil { + self.addSubview(actionButtonView) + } + transition.setFrame(view: actionButtonView, frame: actionButtonFrame) + } + + contentHeight += bottomPanelHeight + initialContentHeight += bottomPanelHeight + + let containerInset: CGFloat = environment.statusBarHeight + 10.0 + let topInset: CGFloat = max(0.0, availableSize.height - containerInset - initialContentHeight) + + let scrollContentHeight = max(topInset + contentHeight, availableSize.height - containerInset) + + self.scrollContentClippingView.layer.cornerRadius = 10.0 + + self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset) + + transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) + + transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) + transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) + + let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset + 56.0), size: CGSize(width: availableSize.width - sideInset * 2.0, height: actionButtonFrame.minY - 24.0 - (containerInset + 56.0))) + transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) + transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) + + self.ignoreScrolling = true + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height - containerInset))) + let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + if resetScrolling { + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) + } + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + 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) + } +} + +public class ChatFolderLinkPreviewScreen: ViewControllerComponentContainer { + private let context: AccountContext + private var linkContents: ChatFolderLinkContents? + private var linkContentsDisposable: Disposable? + + private var isDismissed: Bool = false + + public init(context: AccountContext, slug: String) { + self.context = context + + super.init(context: context, component: ChatFolderLinkPreviewScreenComponent(context: context, slug: slug, linkContents: nil), navigationBarAppearance: .none) + + self.statusBar.statusBarStyle = .Ignore + self.navigationPresentation = .flatModal + self.blocksBackgroundWhenInOverlay = true + + self.linkContentsDisposable = (context.engine.peers.checkChatFolderLink(slug: slug) + //|> delay(0.2, queue: .mainQueue()) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + self.linkContents = result + self.updateComponent(component: AnyComponent(ChatFolderLinkPreviewScreenComponent(context: context, slug: slug, linkContents: result)), transition: .immediate) + }) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.linkContentsDisposable?.dispose() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.disablesInteractiveModalDismiss = true + + if let componentView = self.node.hostView.componentView as? ChatFolderLinkPreviewScreenComponent.View { + componentView.animateIn() + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + + if let componentView = self.node.hostView.componentView as? ChatFolderLinkPreviewScreenComponent.View { + componentView.animateOut(completion: { [weak self] in + completion?() + self?.dismiss(animated: false) + }) + } else { + self.dismiss(animated: false) + } + } + } +} diff --git a/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/PeerListItemComponent.swift new file mode 100644 index 0000000000..eb72a2a56e --- /dev/null +++ b/submodules/TelegramUI/Components/ChatFolderLinkPreviewScreen/Sources/PeerListItemComponent.swift @@ -0,0 +1,327 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import AccountContext +import TelegramCore +import MultilineTextComponent +import Postbox +import AvatarNode +import TelegramPresentationData +import CheckNode +import TelegramStringFormatting + +private let avatarFont = avatarPlaceholderFont(size: 15.0) + +private func cancelContextGestures(view: UIView) { + if let gestureRecognizers = view.gestureRecognizers { + for gesture in gestureRecognizers { + if let gesture = gesture as? ContextGesture { + gesture.cancel() + } + } + } + for subview in view.subviews { + cancelContextGestures(view: subview) + } +} + +final class PeerListItemComponent: Component { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool, isTinted: Bool) + } + + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let sideInset: CGFloat + let title: String + let peer: EnginePeer? + let subtitle: String? + let selectionState: SelectionState + let hasNext: Bool + let action: (EnginePeer) -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + sideInset: CGFloat, + title: String, + peer: EnginePeer?, + subtitle: String?, + selectionState: SelectionState, + hasNext: Bool, + action: @escaping (EnginePeer) -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.sideInset = sideInset + self.title = title + self.peer = peer + self.subtitle = subtitle + self.selectionState = selectionState + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.peer != rhs.peer { + return false + } + if lhs.subtitle != rhs.subtitle { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + final class View: UIView { + private let containerButton: HighlightTrackingButton + + private let title = ComponentView() + private let label = ComponentView() + private let separatorLayer: SimpleLayer + private let avatarNode: AvatarNode + + private var checkLayer: CheckLayer? + + private var component: PeerListItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + + self.containerButton = HighlightTrackingButton() + + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.isLayerBacked = true + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + self.addSubview(self.containerButton) + self.containerButton.layer.addSublayer(self.avatarNode.layer) + + self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component, let peer = component.peer else { + return + } + component.action(peer) + } + + func update(component: PeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + var hasSelectionUpdated = false + if let previousComponent = self.component { + switch previousComponent.selectionState { + case .none: + if case .none = component.selectionState { + } else { + hasSelectionUpdated = true + } + case .editing: + if case .editing = component.selectionState { + } else { + hasSelectionUpdated = true + } + } + } + + self.component = component + self.state = state + + let contextInset: CGFloat = 0.0 + + let height: CGFloat = 60.0 + let verticalInset: CGFloat = 1.0 + var leftInset: CGFloat = 62.0 + component.sideInset + let rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset + var avatarLeftInset: CGFloat = component.sideInset + 10.0 + + if case let .editing(isSelected, isTinted) = component.selectionState { + leftInset += 44.0 + avatarLeftInset += 44.0 + let checkSize: CGFloat = 22.0 + + let checkLayer: CheckLayer + if let current = self.checkLayer { + checkLayer = current + if themeUpdated { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.35) + } + checkLayer.theme = theme + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + var theme = CheckNodeTheme(theme: component.theme, style: .plain) + if isTinted { + theme.backgroundColor = theme.backgroundColor.mixedWith(component.theme.list.itemBlocksBackgroundColor, alpha: 0.35) + } + checkLayer = CheckLayer(theme: theme) + self.checkLayer = checkLayer + self.containerButton.layer.addSublayer(checkLayer) + checkLayer.frame = CGRect(origin: CGPoint(x: -checkSize, y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize)) + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: CGRect(origin: CGPoint(x: floor((54.0 - checkSize) * 0.5), y: floor((height - verticalInset * 2.0 - checkSize) / 2.0)), size: CGSize(width: checkSize, height: checkSize))) + } else { + if let checkLayer = self.checkLayer { + self.checkLayer = nil + transition.setPosition(layer: checkLayer, position: CGPoint(x: -checkLayer.bounds.width * 0.5, y: checkLayer.position.y), completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } + } + + let avatarSize: CGFloat = 40.0 + + let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floor((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) + if self.avatarNode.bounds.isEmpty { + self.avatarNode.frame = avatarFrame + } else { + transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame) + } + if let peer = component.peer { + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + if peer.id == component.context.account.peerId { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: .savedMessagesIcon, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } else { + self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) + } + } + + //TODO:localize + let labelData: (String, Bool) + if let subtitle = component.subtitle { + labelData = (subtitle, false) + } else if case .legacyGroup = component.peer { + labelData = ("group", false) + } else if case let .channel(channel) = component.peer { + if case .group = channel.info { + labelData = ("group", false) + } else { + labelData = ("channel", false) + } + } else { + labelData = ("group", false) + } + + let labelSize = self.label.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: labelData.0, font: Font.regular(15.0), textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let previousTitleFrame = self.title.view?.frame + var previousTitleContents: UIView? + if hasSelectionUpdated && !"".isEmpty { + previousTitleContents = self.title.view?.snapshotView(afterScreenUpdates: false) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + ) + + let titleSpacing: CGFloat = 1.0 + let centralContentHeight: CGFloat = titleSize.height + labelSize.height + titleSpacing + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + if let previousTitleFrame, previousTitleFrame.origin.x != titleFrame.origin.x { + transition.animatePosition(view: titleView, from: CGPoint(x: previousTitleFrame.origin.x - titleFrame.origin.x, y: 0.0), to: CGPoint(), additive: true) + } + + if let previousTitleFrame, let previousTitleContents, previousTitleFrame.size != titleSize { + previousTitleContents.frame = CGRect(origin: previousTitleFrame.origin, size: previousTitleFrame.size) + self.addSubview(previousTitleContents) + + transition.setFrame(view: previousTitleContents, frame: CGRect(origin: titleFrame.origin, size: previousTitleFrame.size)) + transition.setAlpha(view: previousTitleContents, alpha: 0.0, completion: { [weak previousTitleContents] _ in + previousTitleContents?.removeFromSuperview() + }) + transition.animateAlpha(view: titleView, from: 0.0, to: 1.0) + } + } + if let labelView = self.label.view { + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.containerButton.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing), size: labelSize)) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor + } + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + self.separatorLayer.isHidden = !component.hasNext + + let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) + transition.setFrame(view: self.containerButton, frame: containerFrame) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift index 1bdf8d5238..f3b45b9945 100644 --- a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift @@ -1021,6 +1021,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { } else { strongSelf.controllerInteraction.presentController(textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.controller?.updatedPresentationData, title: nil, text: strongSelf.presentationData.strings.Chat_ErrorInvoiceNotFound, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})]), nil) } + case .chatFolder: + break case let .instantView(webpage, anchor): strongSelf.pushController(InstantPageController(context: strongSelf.context, webPage: webpage, sourceLocation: InstantPageSourceLocation(userLocation: .peer(strongSelf.peer.id), peerType: .channel), anchor: anchor)) case let .join(link): diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index ae23ba00c4..12a02756dd 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -29,6 +29,7 @@ import WebUI import BotPaymentsUI import PremiumUI import AuthorizationUI +import ChatFolderLinkPreviewScreen private func defaultNavigationForPeerId(_ peerId: PeerId?, navigation: ChatControllerInteractionNavigateToPeer) -> ChatControllerInteractionNavigateToPeer { if case .default = navigation { @@ -750,5 +751,9 @@ func openResolvedUrlImpl(_ resolvedUrl: ResolvedUrl, context: AccountContext, ur } else { present(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.Chat_ErrorInvoiceNotFound, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), nil) } + case let .chatFolder(slug): + if let navigationController = navigationController { + navigationController.pushViewController(ChatFolderLinkPreviewScreen(context: context, slug: slug)) + } } } diff --git a/submodules/TelegramUI/Sources/OpenUrl.swift b/submodules/TelegramUI/Sources/OpenUrl.swift index 1e3921eeb7..c388cc39cd 100644 --- a/submodules/TelegramUI/Sources/OpenUrl.swift +++ b/submodules/TelegramUI/Sources/OpenUrl.swift @@ -837,6 +837,22 @@ func openExternalUrlImpl(context: AccountContext, urlContext: OpenURLContext, ur } } handleResolvedUrl(.premiumOffer(reference: reference)) + } else if parsedUrl.host == "folder" { + if let components = URLComponents(string: "/?" + query) { + var slug: String? + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "slug" { + slug = value + } + } + } + } + if let slug = slug { + convertedUrl = "https://t.me/folder/\(slug)" + } + } } } else { if parsedUrl.host == "importStickers" { diff --git a/submodules/UrlHandling/Sources/UrlHandling.swift b/submodules/UrlHandling/Sources/UrlHandling.swift index b4dd071d1e..099656200b 100644 --- a/submodules/UrlHandling/Sources/UrlHandling.swift +++ b/submodules/UrlHandling/Sources/UrlHandling.swift @@ -97,6 +97,7 @@ public enum ParsedInternalUrl { case phone(String, String?, String?) case startAttach(String, String?, String?) case contactToken(String) + case chatFolder(slug: String) } private enum ParsedUrl { @@ -417,6 +418,8 @@ public func parseInternalUrl(query: String) -> ParsedInternalUrl? { return .wallpaper(parameter) } else if pathComponents[0] == "addtheme" { return .theme(pathComponents[1]) + } else if pathComponents[0] == "folder" { + return .chatFolder(slug: pathComponents[1]) } else if pathComponents.count == 3 && pathComponents[0] == "c" { if let channelId = Int64(pathComponents[1]), let messageId = Int32(pathComponents[2]) { var threadId: Int32? @@ -774,6 +777,8 @@ private func resolveInternalUrl(context: AccountContext, url: ParsedInternalUrl) } case let .stickerPack(name, type): return .single(.stickerPack(name: name, type: type)) + case let .chatFolder(slug): + return .single(.chatFolder(slug: slug)) case let .invoice(slug): return context.engine.payments.fetchBotPaymentInvoice(source: .slug(slug)) |> map(Optional.init)