From 46881c65ca14af65d63f4aef0006fe74ae10428e Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Fri, 16 Feb 2024 22:52:01 +0400 Subject: [PATCH] [WIP] Business --- .../Sources/AccountContext.swift | 7 +- .../Sources/ChatController.swift | 27 +- .../AccountContext/Sources/MediaManager.swift | 4 +- .../Sources/Node/ChatListItem.swift | 133 +++- .../ChatPresentationInterfaceState.swift | 4 +- .../GalleryUI/Sources/GalleryController.swift | 2 +- .../Sources/ItemListPeerActionItem.swift | 6 + submodules/Postbox/Sources/ChatLocation.swift | 6 +- submodules/Postbox/Sources/Message.swift | 4 + .../Postbox/Sources/MessageHistoryView.swift | 2 +- submodules/Postbox/Sources/Postbox.swift | 13 +- .../Sources/SearchPeerMembers.swift | 2 +- .../Sources/State/AccountViewTracker.swift | 6 +- .../Sources/PresenceStrings.swift | 71 ++- .../ChatMessageAnimatedStickerItemNode.swift | 9 +- .../ChatMessageAttachedContentNode.swift | 3 +- .../Sources/ChatMessageBubbleItemNode.swift | 7 +- .../ChatMessageContactBubbleContentNode.swift | 6 +- .../ChatMessageFileBubbleContentNode.swift | 8 +- ...MessageInstantVideoBubbleContentNode.swift | 32 +- .../ChatMessageInstantVideoItemNode.swift | 4 +- ...atMessageInteractiveInstantVideoNode.swift | 4 + .../Sources/ChatMessageItemImpl.swift | 5 +- .../ChatMessageMapBubbleContentNode.swift | 6 +- .../ChatMessageMediaBubbleContentNode.swift | 6 +- .../ChatMessagePollBubbleContentNode.swift | 8 +- ...atMessageRestrictedBubbleContentNode.swift | 32 +- .../Sources/ChatMessageStickerItemNode.swift | 11 +- .../ChatMessageTextBubbleContentNode.swift | 3 + .../ListItemSliderSelectorComponent/BUILD | 3 +- .../ListItemSliderSelectorComponent.swift | 501 +++------------ .../Settings/BusinessHoursSetupScreen/BUILD | 2 + .../Sources/BusinessDaySetupScreen.swift | 1 + .../Sources/BusinessHoursSetupScreen.swift | 84 ++- .../Sources/BusinessSetupScreen.swift | 14 +- .../Settings/GreetingMessageSetupScreen/BUILD | 10 + .../GreetingMessageListItemComponent.swift | 314 ++++++++++ .../GreetingMessageSetupChatContents.swift | 213 +++++++ .../Sources/GreetingMessageSetupScreen.swift | 589 +++++++++++++++--- .../QuickReplyEmptyStateComponent.swift | 181 ++++++ .../Sources/QuickReplySetupScreen.swift | 563 +++++++++++++++++ .../QuickReplyNameAlertController/BUILD | 28 + .../QuickReplyNameAlertController.swift | 508 +++++++++++++++ .../Settings/TimezoneSelectionScreen/BUILD | 30 + .../Sources/TimezoneSelectionScreen.swift | 156 +++++ .../Sources/TimezoneSelectionScreenNode.swift | 477 ++++++++++++++ .../Components/SliderComponent/BUILD | 3 +- .../Sources/SliderComponent.swift | 431 ++----------- .../Components/TimeSelectionActionSheet/BUILD | 25 + .../Sources/TimeSelectionActionSheet.swift | 8 +- .../Resources/Animations/ZzzEmoji.tgs | Bin 0 -> 38806 bytes .../TelegramUI/Sources/AccountContext.swift | 8 +- ...ChatControllerOpenMessageContextMenu.swift | 2 +- .../TelegramUI/Sources/ChatController.swift | 153 +++-- .../Sources/ChatControllerNode.swift | 14 + .../ChatControllerOpenAttachmentMenu.swift | 247 ++++---- .../ChatControllerOpenCalendarSearch.swift | 4 +- .../Sources/ChatControllerUpdateSearch.swift | 4 +- .../TelegramUI/Sources/ChatEmptyNode.swift | 72 ++- .../Sources/ChatHistoryListNode.swift | 53 +- .../Sources/ChatHistoryViewForLocation.swift | 2 +- .../ChatInterfaceStateContextMenus.swift | 66 +- .../ChatInterfaceStateInputPanels.swift | 20 +- .../ChatInterfaceStateNavigationButtons.swift | 21 +- ...essageContextControllerContentSource.swift | 7 +- .../ChatRestrictedInputPanelNode.swift | 8 + .../ChatSearchNavigationContentNode.swift | 4 +- .../Sources/ChatTextInputPanelNode.swift | 74 ++- .../Sources/SharedAccountContext.swift | 8 +- 69 files changed, 4103 insertions(+), 1236 deletions(-) create mode 100644 submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageListItemComponent.swift create mode 100644 submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupChatContents.swift create mode 100644 submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplyEmptyStateComponent.swift create mode 100644 submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplySetupScreen.swift create mode 100644 submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/BUILD create mode 100644 submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift create mode 100644 submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/BUILD create mode 100644 submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreen.swift create mode 100644 submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift create mode 100644 submodules/TelegramUI/Components/TimeSelectionActionSheet/BUILD rename submodules/TelegramUI/Components/{Settings/BusinessHoursSetupScreen => TimeSelectionActionSheet}/Sources/TimeSelectionActionSheet.swift (93%) create mode 100644 submodules/TelegramUI/Resources/Animations/ZzzEmoji.tgs diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 71de4c35f8..c1a7ae3a31 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -395,13 +395,13 @@ public enum ChatSearchDomain: Equatable { public enum ChatLocation: Equatable { case peer(id: PeerId) case replyThread(message: ChatReplyThreadMessage) - case feed(id: Int32) + case customChatContents } public extension ChatLocation { var normalized: ChatLocation { switch self { - case .peer, .feed: + case .peer, .customChatContents: return self case let .replyThread(message): return .replyThread(message: message.normalized) @@ -936,7 +936,8 @@ public protocol SharedAccountContext: AnyObject { func makeChatbotSetupScreen(context: AccountContext) -> ViewController func makeBusinessLocationSetupScreen(context: AccountContext) -> ViewController func makeBusinessHoursSetupScreen(context: AccountContext) -> ViewController - func makeGreetingMessageSetupScreen(context: AccountContext) -> ViewController + func makeGreetingMessageSetupScreen(context: AccountContext, isAwayMode: Bool) -> ViewController + func makeQuickReplySetupScreen(context: AccountContext) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) func navigateToForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, messageId: EngineMessage.Id?, navigationController: NavigationController, activateInput: ChatControllerActivateInput?, keepStack: NavigateToChatKeepStack) -> Signal diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 838cad2405..947b186ec8 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -744,6 +744,7 @@ public enum ChatControllerSubject: Equatable { case scheduledMessages case pinnedMessages(id: EngineMessage.Id?) case messageOptions(peerIds: [EnginePeer.Id], ids: [EngineMessage.Id], info: MessageOptionsInfo) + case customChatContents(contents: ChatCustomContentsProtocol) public static func ==(lhs: ChatControllerSubject, rhs: ChatControllerSubject) -> Bool { switch lhs { @@ -771,6 +772,12 @@ public enum ChatControllerSubject: Equatable { } else { return false } + case let .customChatContents(lhsValue): + if case let .customChatContents(rhsValue) = rhs, lhsValue === rhsValue { + return true + } else { + return false + } } } @@ -1050,7 +1057,23 @@ public enum ChatHistoryListSource { } case `default` - case custom(messages: Signal<([Message], Int32, Bool), NoError>, messageId: MessageId, quote: Quote?, loadMore: (() -> Void)?) + case custom(messages: Signal<([Message], Int32, Bool), NoError>, messageId: MessageId?, quote: Quote?, loadMore: (() -> Void)?) +} + +public enum ChatCustomContentsKind: Equatable { + case greetingMessageInput + case awayMessageInput + case quickReplyMessageInput(shortcut: String) +} + +public protocol ChatCustomContentsProtocol: AnyObject { + var kind: ChatCustomContentsKind { get } + var messages: Signal<[Message], NoError> { get } + var messageLimit: Int? { get } + + func enqueueMessages(messages: [EnqueueMessage]) + func deleteMessages(ids: [EngineMessage.Id]) + func editMessage(id: EngineMessage.Id, text: String, media: RequestEditMessageMedia, entities: TextEntitiesMessageAttribute?, webpagePreviewAttribute: WebpagePreviewMessageAttribute?, disableUrlPreview: Bool) } public enum ChatHistoryListDisplayHeaders { @@ -1069,7 +1092,7 @@ public protocol ChatControllerInteractionProtocol: AnyObject { public enum ChatHistoryNodeHistoryState: Equatable { case loading - case loaded(isEmpty: Bool) + case loaded(isEmpty: Bool, hasReachedLimits: Bool) } public protocol ChatHistoryListNode: ListView { diff --git a/submodules/AccountContext/Sources/MediaManager.swift b/submodules/AccountContext/Sources/MediaManager.swift index 02cda6c109..092fc4c52b 100644 --- a/submodules/AccountContext/Sources/MediaManager.swift +++ b/submodules/AccountContext/Sources/MediaManager.swift @@ -36,8 +36,8 @@ public enum PeerMessagesPlaylistLocation: Equatable, SharedMediaPlaylistLocation return .peer(peerId) case let .replyThread(replyThreaMessage): return .peer(replyThreaMessage.peerId) - case let .feed(id): - return .feed(id) + case .customChatContents: + return .custom } case let .singleMessage(id): return .peer(id.peerId) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 862e359ee8..22a06898ba 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -26,6 +26,7 @@ import TextNodeWithEntities import ComponentFlow import EmojiStatusComponent import AvatarVideoNode +import AppBundle public enum ChatListItemContent { public struct ThreadInfo: Equatable { @@ -89,6 +90,16 @@ public enum ChatListItemContent { } } + public struct CustomMessageListData: Equatable { + public var commandPrefix: String? + public var messageCount: Int? + + public init(commandPrefix: String?, messageCount: Int?) { + self.commandPrefix = commandPrefix + self.messageCount = messageCount + } + } + public struct PeerData { public var messages: [EngineMessage] public var peer: EngineRenderedPeer @@ -112,6 +123,7 @@ public enum ChatListItemContent { public var requiresPremiumForMessaging: Bool public var displayAsTopicList: Bool public var tags: [Tag] + public var customMessageListData: CustomMessageListData? public init( messages: [EngineMessage], @@ -135,7 +147,8 @@ public enum ChatListItemContent { storyState: StoryState?, requiresPremiumForMessaging: Bool, displayAsTopicList: Bool, - tags: [Tag] + tags: [Tag], + customMessageListData: CustomMessageListData? = nil ) { self.messages = messages self.peer = peer @@ -159,6 +172,7 @@ public enum ChatListItemContent { self.requiresPremiumForMessaging = requiresPremiumForMessaging self.displayAsTopicList = displayAsTopicList self.tags = tags + self.customMessageListData = customMessageListData } } @@ -1153,6 +1167,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let inputActivitiesNode: ChatListInputActivitiesNode let dateNode: TextNode var dateStatusIconNode: ASImageNode? + var dateDisclosureIconView: UIImageView? let separatorNode: ASDisplayNode let statusNode: ChatListStatusNode let badgeNode: ChatListBadgeNode @@ -1587,7 +1602,8 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if let peer = peer { var overrideImage: AvatarNodeImageOverride? - if peer.id.isReplies { + if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { + } else if peer.id.isReplies { overrideImage = .repliesIcon } else if peer.id.isAnonymousSavedMessages { overrideImage = .anonymousSavedMessagesIcon @@ -2021,15 +2037,21 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let enableChatListPhotos = true - let avatarDiameter = min(60.0, floor(item.presentationData.fontSize.baseDisplaySize * 60.0 / 17.0)) + var avatarDiameter = min(60.0, floor(item.presentationData.fontSize.baseDisplaySize * 60.0 / 17.0)) let avatarLeftInset: CGFloat - if item.interaction.isInlineMode { - avatarLeftInset = 12.0 - } else if !useChatListLayout { - avatarLeftInset = 50.0 - } else { + + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { + avatarDiameter = 40.0 avatarLeftInset = 18.0 + avatarDiameter + } else { + if item.interaction.isInlineMode { + avatarLeftInset = 12.0 + } else if !useChatListLayout { + avatarLeftInset = 50.0 + } else { + avatarLeftInset = 18.0 + avatarDiameter + } } let badgeDiameter = floor(item.presentationData.fontSize.baseDisplaySize * 20.0 / 17.0) @@ -2083,7 +2105,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { hideAuthor = true } - let attributedText: NSAttributedString + var attributedText: NSAttributedString var hasDraft = false var inlineAuthorPrefix: String? @@ -2401,6 +2423,13 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { attributedText = composedString + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, let commandPrefix = customMessageListData.commandPrefix { + let mutableAttributedText = NSMutableAttributedString(attributedString: attributedText) + let boldTextFont = Font.semibold(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0)) + mutableAttributedText.insert(NSAttributedString(string: commandPrefix + " ", font: boldTextFont, textColor: theme.titleColor), at: 0) + attributedText = mutableAttributedText + } + if !ignoreForwardedIcon { if case .savedMessagesChats = item.chatListLocation { displayForwardedIcon = false @@ -2548,7 +2577,21 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { switch contentData { case let .chat(itemPeer, threadInfo, _, _, _, _, _): - if let threadInfo = threadInfo { + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData { + if customMessageListData.commandPrefix != nil { + titleAttributedString = nil + } else { + if let displayTitle = itemPeer.chatMainPeer?.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) { + let textColor: UIColor + if case let .chatList(index) = item.index, index.messageIndex.id.peerId.namespace == Namespaces.Peer.SecretChat { + textColor = theme.secretTitleColor + } else { + textColor = theme.titleColor + } + titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: textColor) + } + } + } else if let threadInfo = threadInfo { titleAttributedString = NSAttributedString(string: threadInfo.info.title, font: titleFont, textColor: theme.titleColor) } else if let message = messages.last, case let .user(author) = message.author, displayAsMessage { titleAttributedString = NSAttributedString(string: author.id == account.peerId ? item.presentationData.strings.DialogList_You : EnginePeer.user(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder), font: titleFont, textColor: theme.titleColor) @@ -2587,7 +2630,13 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { case let .peer(peerData): topIndex = peerData.messages.first?.index } - if let topIndex { + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData { + if let messageCount = customMessageListData.messageCount { + dateText = "\(messageCount)" + } else { + dateText = " " + } + } else if let topIndex { var t = Int(topIndex.timestamp) var timeinfo = tm() localtime_r(&t, &timeinfo) @@ -2754,7 +2803,9 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { switch item.content { case let .peer(peerData): if let peer = peerData.messages.last?.author { - if case .savedMessagesChats = item.chatListLocation, peer.id == item.context.account.peerId { + if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { + currentCredibilityIconContent = nil + } else if case .savedMessagesChats = item.chatListLocation, peer.id == item.context.account.peerId { currentCredibilityIconContent = nil } else if peer.isScam { currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased()) @@ -2776,7 +2827,9 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { break } } else if case let .chat(itemPeer) = contentPeer, let peer = itemPeer.chatMainPeer { - if case .savedMessagesChats = item.chatListLocation, peer.id == item.context.account.peerId { + if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { + currentCredibilityIconContent = nil + } else if case .savedMessagesChats = item.chatListLocation, peer.id == item.context.account.peerId { currentCredibilityIconContent = nil } else if peer.isScam { currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased()) @@ -3066,16 +3119,21 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { animateContent = true } - let (measureLayout, measureApply) = makeMeasureLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + let (measureLayout, measureApply) = makeMeasureLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: " ", font: titleFont, textColor: .black), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let titleSpacing: CGFloat = -1.0 let authorSpacing: CGFloat = -3.0 var itemHeight: CGFloat = 8.0 * 2.0 + 1.0 itemHeight -= 21.0 - itemHeight += titleLayout.size.height - itemHeight += measureLayout.size.height * 3.0 - itemHeight += titleSpacing - itemHeight += authorSpacing + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { + itemHeight += measureLayout.size.height * 2.0 + itemHeight += 22.0 + } else { + itemHeight += titleLayout.size.height + itemHeight += measureLayout.size.height * 3.0 + itemHeight += titleSpacing + itemHeight += authorSpacing + } let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: layoutOffset + floor(item.presentationData.fontSize.itemListBaseFontSize * 8.0 / 17.0)), size: CGSize(width: rawContentWidth, height: itemHeight - 12.0 - 9.0)) @@ -3466,7 +3524,32 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let _ = mentionBadgeApply(animateBadges, true) let _ = onlineApply(animateContent && animateOnline) - transition.updateFrame(node: strongSelf.dateNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x + contentRect.size.width - dateLayout.size.width, y: contentRect.origin.y + 2.0), size: dateLayout.size)) + var dateFrame = CGRect(origin: CGPoint(x: contentRect.origin.x + contentRect.size.width - dateLayout.size.width, y: contentRect.origin.y + 2.0), size: dateLayout.size) + + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.messageCount != nil { + dateFrame.origin.x -= 10.0 + + let dateDisclosureIconView: UIImageView + if let current = strongSelf.dateDisclosureIconView { + dateDisclosureIconView = current + } else { + dateDisclosureIconView = UIImageView(image: UIImage(bundleImageName: "Item List/DisclosureArrow")?.withRenderingMode(.alwaysTemplate)) + strongSelf.dateDisclosureIconView = dateDisclosureIconView + strongSelf.mainContentContainerNode.view.addSubview(dateDisclosureIconView) + } + dateDisclosureIconView.tintColor = item.presentationData.theme.list.disclosureArrowColor + let iconScale: CGFloat = 0.7 + if let image = dateDisclosureIconView.image { + let imageSize = CGSize(width: floor(image.size.width * iconScale), height: floor(image.size.height * iconScale)) + let iconFrame = CGRect(origin: CGPoint(x: contentRect.origin.x + contentRect.size.width - imageSize.width + 4.0, y: floorToScreenPixels(dateFrame.midY - imageSize.height * 0.5)), size: imageSize) + dateDisclosureIconView.frame = iconFrame + } + } else if let dateDisclosureIconView = strongSelf.dateDisclosureIconView { + strongSelf.dateDisclosureIconView = nil + dateDisclosureIconView.removeFromSuperview() + } + + transition.updateFrame(node: strongSelf.dateNode, frame: dateFrame) var statusOffset: CGFloat = 0.0 if let dateIconImage { @@ -3997,6 +4080,12 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { transition.updateAlpha(node: strongSelf.separatorNode, alpha: 1.0) } + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData { + if customMessageListData.messageCount != nil { + strongSelf.separatorNode.isHidden = true + } + } + transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: itemHeight))) let backgroundColor: UIColor let highlightedBackgroundColor: UIColor @@ -4012,7 +4101,11 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { highlightedBackgroundColor = theme.pinnedItemHighlightedBackgroundColor } } else { - backgroundColor = theme.itemBackgroundColor + if case let .peer(peerData) = item.content, peerData.customMessageListData != nil { + backgroundColor = .clear + } else { + backgroundColor = theme.itemBackgroundColor + } highlightedBackgroundColor = theme.itemHighlightedBackgroundColor } diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift index 2bf79006ef..35ccabb4ea 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift @@ -17,7 +17,7 @@ public extension ChatLocation { return peerId case let .replyThread(replyThreadMessage): return replyThreadMessage.peerId - case .feed: + case .customChatContents: return nil } } @@ -28,7 +28,7 @@ public extension ChatLocation { return nil case let .replyThread(replyThreadMessage): return replyThreadMessage.threadId - case .feed: + case .customChatContents: return nil } } diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index e9c8981b14..a715bd32af 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -605,7 +605,7 @@ public class GalleryController: ViewController, StandalonePresentableController, case let .replyThread(message): peerIdValue = message.peerId threadIdValue = message.threadId - case .feed: + case .customChatContents: break } if peerIdValue == context.account.peerId, let customTag { diff --git a/submodules/ItemListPeerActionItem/Sources/ItemListPeerActionItem.swift b/submodules/ItemListPeerActionItem/Sources/ItemListPeerActionItem.swift index 591f017748..d51d900bf3 100644 --- a/submodules/ItemListPeerActionItem/Sources/ItemListPeerActionItem.swift +++ b/submodules/ItemListPeerActionItem/Sources/ItemListPeerActionItem.swift @@ -55,6 +55,9 @@ public class ItemListPeerActionItem: ListViewItem, ItemListItem { if self.alwaysPlain { neighbors.top = .sameSection(alwaysPlain: false) } + if self.alwaysPlain { + neighbors.bottom = .sameSection(alwaysPlain: false) + } let (layout, apply) = node.asyncLayout()(self, params, neighbors) node.contentSize = layout.contentSize @@ -83,6 +86,9 @@ public class ItemListPeerActionItem: ListViewItem, ItemListItem { if self.alwaysPlain { neighbors.top = .sameSection(alwaysPlain: false) } + if self.alwaysPlain { + neighbors.bottom = .sameSection(alwaysPlain: false) + } let (layout, apply) = makeLayout(self, params, neighbors) Queue.mainQueue().async { completion(layout, { _ in diff --git a/submodules/Postbox/Sources/ChatLocation.swift b/submodules/Postbox/Sources/ChatLocation.swift index 45a6eaacd0..b4e4cfdb54 100644 --- a/submodules/Postbox/Sources/ChatLocation.swift +++ b/submodules/Postbox/Sources/ChatLocation.swift @@ -4,7 +4,7 @@ import SwiftSignalKit public enum ChatLocationInput { case peer(peerId: PeerId, threadId: Int64?) case thread(peerId: PeerId, threadId: Int64, data: Signal) - case feed(id: Int32, data: Signal) + case customChatContents } public extension ChatLocationInput { @@ -14,7 +14,7 @@ public extension ChatLocationInput { return peerId case let .thread(peerId, _, _): return peerId - case .feed: + case .customChatContents: return nil } } @@ -25,7 +25,7 @@ public extension ChatLocationInput { return threadId case let .thread(_, threadId, _): return threadId - case .feed: + case .customChatContents: return nil } } diff --git a/submodules/Postbox/Sources/Message.swift b/submodules/Postbox/Sources/Message.swift index 4a85acefda..3f465a5af5 100644 --- a/submodules/Postbox/Sources/Message.swift +++ b/submodules/Postbox/Sources/Message.swift @@ -639,6 +639,10 @@ public extension MessageAttribute { public struct MessageGroupInfo: Equatable { public let stableId: UInt32 + + public init(stableId: UInt32) { + self.stableId = stableId + } } public final class Message { diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index 22cf028d3d..593d94ad7d 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -1101,7 +1101,7 @@ public final class MessageHistoryView { self.topTaggedMessages = [] self.additionalData = [] self.isLoading = isLoading - self.isLoadingEarlier = true + self.isLoadingEarlier = false self.isAddedToChatList = false self.peerStoryStats = [:] } diff --git a/submodules/Postbox/Sources/Postbox.swift b/submodules/Postbox/Sources/Postbox.swift index 5666aa37d8..33201d99a7 100644 --- a/submodules/Postbox/Sources/Postbox.swift +++ b/submodules/Postbox/Sources/Postbox.swift @@ -2997,6 +2997,8 @@ final class PostboxImpl { private func internalTransaction(_ f: (Transaction) -> T) -> (result: T, updatedTransactionStateVersion: Int64?, updatedMasterClientId: Int64?) { let _ = self.isInTransaction.swap(true) + let startTime = CFAbsoluteTimeGetCurrent() + self.valueBox.begin() let transaction = Transaction(queue: self.queue, postbox: self) self.afterBegin(transaction: transaction) @@ -3005,6 +3007,12 @@ final class PostboxImpl { transaction.disposed = true self.valueBox.commit() + let endTime = CFAbsoluteTimeGetCurrent() + let transactionDuration = endTime - startTime + if transactionDuration > 0.1 { + postboxLog("Postbox transaction took \(transactionDuration * 1000.0) ms") + } + let _ = self.isInTransaction.swap(false) if let currentUpdatedState = self.currentUpdatedState { @@ -3079,7 +3087,7 @@ final class PostboxImpl { switch chatLocation { case let .peer(peerId, threadId): return .single((.peer(peerId: peerId, threadId: threadId), false)) - case .thread(_, _, let data), .feed(_, let data): + case .thread(_, _, let data): return Signal { subscriber in var isHoleFill = false return (data @@ -3089,6 +3097,9 @@ final class PostboxImpl { return (.external(value), wasHoleFill) }).start(next: subscriber.putNext, error: subscriber.putError, completed: subscriber.putCompletion) } + case .customChatContents: + assert(false) + return .never() } } diff --git a/submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift b/submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift index 8cef0161a8..5581cdb9d3 100644 --- a/submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift +++ b/submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift @@ -81,7 +81,7 @@ public func searchPeerMembers(context: AccountContext, peerId: EnginePeer.Id, ch return ActionDisposable { disposable.dispose() } - case .feed: + case .customChatContents: subscriber.putNext(([], true)) return ActionDisposable { diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index d6aff342b5..b0fd855035 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -179,7 +179,7 @@ private func wrappedHistoryViewAdditionalData(chatLocation: ChatLocationInput, a result.append(.peerChatState(peerId)) } } - case .feed: + case .customChatContents: break } return result @@ -1839,7 +1839,7 @@ public final class AccountViewTracker { if peerId.namespace == Namespaces.Peer.CloudChannel { strongSelf.historyViewStateValidationContexts.updateView(id: viewId, view: nil, location: chatLocation) } - case .feed: + case .customChatContents: break } } @@ -1852,7 +1852,7 @@ public final class AccountViewTracker { peerId = peerIdValue case let .thread(peerIdValue, _, _): peerId = peerIdValue - case .feed: + case .customChatContents: peerId = nil } if let peerId = peerId, peerId.namespace == Namespaces.Peer.CloudChannel { diff --git a/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift index b8f5bbd3db..8cdc962093 100644 --- a/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift @@ -107,6 +107,46 @@ public func stringForMonth(strings: PresentationStrings, month: Int32, ofYear ye } } +private func monthAtIndex(_ index: Int, strings: PresentationStrings) -> String { + switch index { + case 0: + return strings.Month_ShortJanuary + case 1: + return strings.Month_ShortFebruary + case 2: + return strings.Month_ShortMarch + case 3: + return strings.Month_ShortApril + case 4: + return strings.Month_ShortMay + case 5: + return strings.Month_ShortJune + case 6: + return strings.Month_ShortJuly + case 7: + return strings.Month_ShortAugust + case 8: + return strings.Month_ShortSeptember + case 9: + return strings.Month_ShortOctober + case 10: + return strings.Month_ShortNovember + case 11: + return strings.Month_ShortDecember + default: + return "" + } +} + +public func stringForCompactDate(timestamp: Int32, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat) -> String { + var t: time_t = time_t(timestamp) + var timeinfo: tm = tm() + localtime_r(&t, &timeinfo) + + //TODO:localize + return "\(shortStringForDayOfWeek(strings: strings, day: timeinfo.tm_wday)) \(timeinfo.tm_mday) \(monthAtIndex(Int(timeinfo.tm_mon), strings: strings))" +} + public enum RelativeTimestampFormatDay { case today case yesterday @@ -362,37 +402,6 @@ public func stringForRelativeLiveLocationUpdateTimestamp(strings: PresentationSt } } -private func monthAtIndex(_ index: Int, strings: PresentationStrings) -> String { - switch index { - case 0: - return strings.Month_GenJanuary - case 1: - return strings.Month_GenFebruary - case 2: - return strings.Month_GenMarch - case 3: - return strings.Month_GenApril - case 4: - return strings.Month_GenMay - case 5: - return strings.Month_GenJune - case 6: - return strings.Month_GenJuly - case 7: - return strings.Month_GenAugust - case 8: - return strings.Month_GenSeptember - case 9: - return strings.Month_GenOctober - case 10: - return strings.Month_GenNovember - case 11: - return strings.Month_GenDecember - default: - return "" - } -} - public func stringForRelativeActivityTimestamp(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, preciseTime: Bool = false, relativeTimestamp: Int32, relativeTo timestamp: Int32) -> String { let difference = timestamp - relativeTimestamp if difference < 60 { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index 0e397b8d9c..29434ba731 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -818,8 +818,6 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { if !isBroadcastChannel { hasAvatar = true - } else if case .feed = item.chatLocation { - hasAvatar = true } } } else if incoming { @@ -844,8 +842,8 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } else if incoming { hasAvatar = true } - case .feed: - hasAvatar = true + case .customChatContents: + hasAvatar = false } if hasAvatar { @@ -1445,6 +1443,9 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { let dateAndStatusFrame = CGRect(origin: CGPoint(x: max(displayLeftInset, updatedImageFrame.maxX - dateAndStatusSize.width - 4.0), y: updatedImageFrame.maxY - dateAndStatusSize.height - 4.0 + imageBottomPadding), size: dateAndStatusSize) animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) dateAndStatusApply(animation) + if case .customChatContents = item.associatedData.subject { + strongSelf.dateAndStatusNode.isHidden = true + } if needsReplyBackground { if strongSelf.replyBackgroundContent == nil, let backgroundContent = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAttachedContentNode/Sources/ChatMessageAttachedContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAttachedContentNode/Sources/ChatMessageAttachedContentNode.swift index 688e432d8d..818da1db8b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAttachedContentNode/Sources/ChatMessageAttachedContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAttachedContentNode/Sources/ChatMessageAttachedContentNode.swift @@ -675,7 +675,8 @@ public final class ChatMessageAttachedContentNode: ASDisplayNode { } var statusLayoutAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))? - if case let .linear(_, bottom) = position { + if case .customChatContents = associatedData.subject { + } else if case let .linear(_, bottom) = position { switch bottom { case .None, .Neighbour(_, .footer, _): if message.adAttribute == nil { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 40e309e644..6c49dba131 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1446,8 +1446,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !isBroadcastChannel { hasAvatar = incoming - } else if case .feed = item.chatLocation { - hasAvatar = true + } else if case .customChatContents = item.chatLocation { + hasAvatar = false } } } else if incoming { @@ -2072,7 +2072,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI maximumNodeWidth = size.width - if mosaicRange.upperBound == contentPropertiesAndLayouts.count || contentPropertiesAndLayouts[contentPropertiesAndLayouts.count - 1].3.isAttachment { + if case .customChatContents = item.associatedData.subject { + } else if mosaicRange.upperBound == contentPropertiesAndLayouts.count || contentPropertiesAndLayouts[contentPropertiesAndLayouts.count - 1].3.isAttachment { let message = item.content.firstMessage var edited = false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageContactBubbleContentNode/Sources/ChatMessageContactBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageContactBubbleContentNode/Sources/ChatMessageContactBubbleContentNode.swift index ff61a4319a..f94d9c3786 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageContactBubbleContentNode/Sources/ChatMessageContactBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageContactBubbleContentNode/Sources/ChatMessageContactBubbleContentNode.swift @@ -252,7 +252,10 @@ public class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? - switch position { + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch position { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if incoming { statusType = .BubbleIncoming @@ -267,6 +270,7 @@ public class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { } default: statusType = nil + } } var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift index 10d7db9283..c9ddf26366 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift @@ -112,8 +112,11 @@ public class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { incoming = false } let statusType: ChatMessageDateAndStatusType? - switch preparePosition { - case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch preparePosition { + case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if incoming { statusType = .BubbleIncoming } else { @@ -127,6 +130,7 @@ public class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { } default: statusType = nil + } } let automaticDownload = shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: selectedFile!) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift index 9eee510b6d..a010d655e0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift @@ -201,21 +201,25 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN } let statusType: ChatMessageDateAndStatusType? - switch preparePosition { - case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): - if incoming { - statusType = .BubbleIncoming - } else { - if item.message.flags.contains(.Failed) { - statusType = .BubbleOutgoing(.Failed) - } else if (item.message.flags.isSending && !item.message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { - statusType = .BubbleOutgoing(.Sending) - } else { - statusType = .BubbleOutgoing(.Sent(read: item.read)) - } - } - default: + if case .customChatContents = item.associatedData.subject { statusType = nil + } else { + switch preparePosition { + case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): + if incoming { + statusType = .BubbleIncoming + } else { + if item.message.flags.contains(.Failed) { + statusType = .BubbleOutgoing(.Failed) + } else if (item.message.flags.isSending && !item.message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { + statusType = .BubbleOutgoing(.Sending) + } else { + statusType = .BubbleOutgoing(.Sent(read: item.read)) + } + } + default: + statusType = nil + } } let automaticDownload = shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: selectedFile!) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index e19651af74..b4e50791ab 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -321,8 +321,8 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureReco if !isBroadcastChannel { hasAvatar = true - } else if case .feed = item.chatLocation { - hasAvatar = true + } else if case .customChatContents = item.chatLocation { + hasAvatar = false } } } else if incoming { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift index a3a48a3c5b..588bf59b55 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveInstantVideoNode/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -949,6 +949,10 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) } + if case .customChatContents = item.associatedData.subject { + strongSelf.dateAndStatusNode.isHidden = true + } + if let videoNode = strongSelf.videoNode { videoNode.bounds = CGRect(origin: CGPoint(), size: videoFrame.size) if strongSelf.imageScale != imageScale { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift index f9883c0e9c..daf37c91fc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift @@ -365,7 +365,10 @@ public final class ChatMessageItemImpl: ChatMessageItem, CustomStringConvertible } self.avatarHeader = avatarHeader - var headers: [ListViewItemHeader] = [self.dateHeader] + var headers: [ListViewItemHeader] = [] + if !self.disableDate { + headers.append(self.dateHeader) + } if case .messageOptions = associatedData.subject { headers = [] } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift index 18cf176515..c1f4322937 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift @@ -223,7 +223,10 @@ public class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? - switch position { + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch position { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if selectedMedia?.venue != nil || activeLiveBroadcastingTimeout != nil { if incoming { @@ -252,6 +255,7 @@ public class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { } default: statusType = nil + } } var statusSize = CGSize() diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift index 85a3503874..61fe835f66 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift @@ -284,7 +284,10 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? - switch preparePosition { + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch preparePosition { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if item.message.effectivelyIncoming(item.context.account.peerId) { statusType = .ImageIncoming @@ -301,6 +304,7 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { statusType = nil default: statusType = nil + } } var isReplyThread = false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index a0261a4de6..28f52c83e9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -986,7 +986,10 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? - switch position { + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch position { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if incoming { statusType = .BubbleIncoming @@ -1001,8 +1004,9 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } default: statusType = nil + } } - + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRestrictedBubbleContentNode/Sources/ChatMessageRestrictedBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRestrictedBubbleContentNode/Sources/ChatMessageRestrictedBubbleContentNode.swift index 6dbf1521f6..5871ffeff5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRestrictedBubbleContentNode/Sources/ChatMessageRestrictedBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRestrictedBubbleContentNode/Sources/ChatMessageRestrictedBubbleContentNode.swift @@ -77,21 +77,25 @@ public class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNod let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? - switch position { - case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): - if incoming { - statusType = .BubbleIncoming - } else { - if message.flags.contains(.Failed) { - statusType = .BubbleOutgoing(.Failed) - } else if message.flags.isSending && !message.isSentOrAcknowledged { - statusType = .BubbleOutgoing(.Sending) - } else { - statusType = .BubbleOutgoing(.Sent(read: item.read)) - } - } - default: + if case .customChatContents = item.associatedData.subject { statusType = nil + } else { + switch position { + case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): + if incoming { + statusType = .BubbleIncoming + } else { + if message.flags.contains(.Failed) { + statusType = .BubbleOutgoing(.Failed) + } else if message.flags.isSending && !message.isSentOrAcknowledged { + statusType = .BubbleOutgoing(.Sending) + } else { + statusType = .BubbleOutgoing(.Sent(read: item.read)) + } + } + default: + statusType = nil + } } let entities = [MessageTextEntity(range: 0.. Void - let title: String - let value: Float - let minValue: Float - let maxValue: Float - let startValue: Float - let isEnabled: Bool - let trackColor: UIColor? - let displayValue: Bool - let valueUpdated: (Float) -> Void - let isTrackingUpdated: ((Bool) -> Void)? - - init( - title: String, - value: Float, - minValue: Float, - maxValue: Float, - startValue: Float, - isEnabled: Bool, - trackColor: UIColor?, - displayValue: Bool, - valueUpdated: @escaping (Float) -> Void, - isTrackingUpdated: ((Bool) -> Void)? = nil + public init( + theme: PresentationTheme, + values: [String], + selectedIndex: Int, + selectedIndexUpdated: @escaping (Int) -> Void ) { - self.title = title - self.value = value - self.minValue = minValue - self.maxValue = maxValue - self.startValue = startValue - self.isEnabled = isEnabled - self.trackColor = trackColor - self.displayValue = displayValue - self.valueUpdated = valueUpdated - self.isTrackingUpdated = isTrackingUpdated + self.theme = theme + self.values = values + self.selectedIndex = selectedIndex + self.selectedIndexUpdated = selectedIndexUpdated } - static func ==(lhs: ListItemSliderSelectorComponent, rhs: ListItemSliderSelectorComponent) -> Bool { - if lhs.title != rhs.title { + public static func ==(lhs: ListItemSliderSelectorComponent, rhs: ListItemSliderSelectorComponent) -> Bool { + if lhs.theme !== rhs.theme { return false } - if lhs.value != rhs.value { + if lhs.values != rhs.values { return false } - if lhs.minValue != rhs.minValue { - return false - } - if lhs.maxValue != rhs.maxValue { - return false - } - if lhs.startValue != rhs.startValue { - return false - } - if lhs.isEnabled != rhs.isEnabled { - return false - } - if lhs.trackColor != rhs.trackColor { - return false - } - if lhs.displayValue != rhs.displayValue { + if lhs.selectedIndex != rhs.selectedIndex { return false } return true } - final class View: UIView, UITextFieldDelegate { - private let title = ComponentView() - private let value = ComponentView() - private var sliderView: TGPhotoEditorSliderView? + public final class View: UIView, ListSectionComponent.ChildView { + private var titles: [ComponentView] = [] + private var slider = ComponentView() private var component: ListItemSliderSelectorComponent? private weak var state: EmptyComponentState? - override init(frame: CGRect) { + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public private(set) var separatorInset: CGFloat = 0.0 + + override public init(frame: CGRect) { super.init(frame: frame) } - required init?(coder: NSCoder) { + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func update(component: ListItemSliderSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + func update(component: ListItemSliderSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state - var internalIsTrackingUpdated: ((Bool) -> Void)? - if let isTrackingUpdated = component.isTrackingUpdated { - internalIsTrackingUpdated = { [weak self] isTracking in - if let self { - if isTracking { - self.sliderView?.bordered = true - } else { - Queue.mainQueue().after(0.1) { - self.sliderView?.bordered = false - } - } - isTrackingUpdated(isTracking) - let transition: Transition - if isTracking { - transition = .immediate - } else { - transition = .easeInOut(duration: 0.25) - } - if let titleView = self.title.view { - transition.setAlpha(view: titleView, alpha: isTracking ? 0.0 : 1.0) - } - if let valueView = self.value.view { - transition.setAlpha(view: valueView, alpha: isTracking ? 0.0 : 1.0) - } - } - } - } - - let sliderView: TGPhotoEditorSliderView - if let current = self.sliderView { - sliderView = current - sliderView.value = CGFloat(component.value) - } else { - sliderView = TGPhotoEditorSliderView() - sliderView.backgroundColor = .clear - sliderView.startColor = UIColor(rgb: 0xffffff) - sliderView.enablePanHandling = true - sliderView.trackCornerRadius = 1.0 - sliderView.lineSize = 2.0 - sliderView.minimumValue = CGFloat(component.minValue) - sliderView.maximumValue = CGFloat(component.maxValue) - sliderView.startValue = CGFloat(component.startValue) - sliderView.value = CGFloat(component.value) - sliderView.disablesInteractiveTransitionGestureRecognizer = true - sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged) - sliderView.layer.allowsGroupOpacity = true - self.sliderView = sliderView - self.addSubview(sliderView) - } - sliderView.interactionBegan = { - internalIsTrackingUpdated?(true) - } - sliderView.interactionEnded = { - internalIsTrackingUpdated?(false) - } + let sideInset: CGFloat = 13.0 + let titleSideInset: CGFloat = 20.0 + let titleClippingSideInset: CGFloat = 14.0 - if component.isEnabled { - sliderView.alpha = 1.3 - sliderView.trackColor = component.trackColor ?? UIColor(rgb: 0xffffff) - sliderView.isUserInteractionEnabled = true - } else { - sliderView.trackColor = UIColor(rgb: 0xffffff) - sliderView.alpha = 0.3 - sliderView.isUserInteractionEnabled = false - } + let titleAreaWidth: CGFloat = availableSize.width - titleSideInset * 2.0 - transition.setFrame(view: sliderView, frame: CGRect(origin: CGPoint(x: 22.0, y: 7.0), size: CGSize(width: availableSize.width - 22.0 * 2.0, height: 44.0))) - sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) - - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent( - Text(text: component.title, font: Font.regular(14.0), color: UIColor(rgb: 0x808080)) - ), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - if let titleView = self.title.view { - if titleView.superview == nil { - self.addSubview(titleView) - } - transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: 21.0, y: 0.0), size: titleSize)) - } - - let valueText: String - if component.displayValue { - if component.value > 0.005 { - valueText = String(format: "+%.2f", component.value) - } else if component.value < -0.005 { - valueText = String(format: "%.2f", component.value) + for i in 0 ..< component.values.count { + var titleTransition = transition + let title: ComponentView + if self.titles.count > i { + title = self.titles[i] } else { - valueText = "" + titleTransition = titleTransition.withAnimation(.none) + title = ComponentView() + self.titles.append(title) } - } else { - valueText = "" - } - - let valueSize = self.value.update( - transition: .immediate, - component: AnyComponent( - Text(text: valueText, font: Font.with(size: 14.0, traits: .monospacedNumbers), color: UIColor(rgb: 0xf8d74a)) - ), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - if let valueView = self.value.view { - if valueView.superview == nil { - self.addSubview(valueView) - } - transition.setFrame(view: valueView, frame: CGRect(origin: CGPoint(x: availableSize.width - 21.0 - valueSize.width, y: 0.0), size: valueSize)) - } - - return CGSize(width: availableSize.width, height: 52.0) - } - - @objc private func sliderValueChanged() { - guard let component = self.component, let sliderView = self.sliderView else { - return - } - component.valueUpdated(Float(sliderView.value)) - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} - -struct AdjustmentTool: Equatable { - let key: EditorToolKey - let title: String - let value: Float - let minValue: Float - let maxValue: Float - let startValue: Float -} - -final class AdjustmentsComponent: Component { - typealias EnvironmentType = Empty - - let tools: [AdjustmentTool] - let valueUpdated: (EditorToolKey, Float) -> Void - let isTrackingUpdated: (Bool) -> Void - - init( - tools: [AdjustmentTool], - valueUpdated: @escaping (EditorToolKey, Float) -> Void, - isTrackingUpdated: @escaping (Bool) -> Void - ) { - self.tools = tools - self.valueUpdated = valueUpdated - self.isTrackingUpdated = isTrackingUpdated - } - - static func ==(lhs: AdjustmentsComponent, rhs: AdjustmentsComponent) -> Bool { - if lhs.tools != rhs.tools { - return false - } - return true - } - - final class View: UIView { - private let scrollView = UIScrollView() - private var toolViews: [ComponentView] = [] - - private var component: AdjustmentsComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - self.scrollView.showsVerticalScrollIndicator = false - - super.init(frame: frame) - - self.addSubview(self.scrollView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: AdjustmentsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - self.component = component - self.state = state - - let valueUpdated = component.valueUpdated - let isTrackingUpdated: (EditorToolKey, Bool) -> Void = { [weak self] trackingTool, isTracking in - component.isTrackingUpdated(isTracking) - - if let self { - for i in 0 ..< component.tools.count { - let tool = component.tools[i] - if tool.key != trackingTool && i < self.toolViews.count { - if let view = self.toolViews[i].view { - let transition: Transition - if isTracking { - transition = .immediate - } else { - transition = .easeInOut(duration: 0.25) - } - transition.setAlpha(view: view, alpha: isTracking ? 0.0 : 1.0) - } - } - } - } - } - - var sizes: [CGSize] = [] - for i in 0 ..< component.tools.count { - let tool = component.tools[i] - let componentView: ComponentView - if i >= self.toolViews.count { - componentView = ComponentView() - self.toolViews.append(componentView) - } else { - componentView = self.toolViews[i] - } - - var valueIsNegative = false - var value = tool.value - if case .enhance = tool.key { - if value < 0.0 { - valueIsNegative = true - } - value = abs(value) - } - - let size = componentView.update( - transition: transition, - component: AnyComponent( - ListItemSliderSelectorComponent( - title: tool.title, - value: value, - minValue: tool.minValue, - maxValue: tool.maxValue, - startValue: tool.startValue, - isEnabled: true, - trackColor: nil, - displayValue: true, - valueUpdated: { value in - var updatedValue = value - if valueIsNegative { - updatedValue *= -1.0 - } - valueUpdated(tool.key, updatedValue) - }, - isTrackingUpdated: { isTracking in - isTrackingUpdated(tool.key, isTracking) - } - ) - ), + let titleSize = title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.values[i], font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor)) + )), environment: {}, - containerSize: availableSize + containerSize: CGSize(width: 100.0, height: 100.0) ) - sizes.append(size) - } - - var origin: CGPoint = CGPoint(x: 0.0, y: 11.0) - for i in 0 ..< component.tools.count { - let size = sizes[i] - let componentView = self.toolViews[i] - - if let view = componentView.view { - if view.superview == nil { - self.scrollView.addSubview(view) - } - transition.setFrame(view: view, frame: CGRect(origin: origin, size: size)) + var titleFrame = CGRect(origin: CGPoint(x: titleSideInset - floor(titleSize.width * 0.5), y: 14.0), size: titleSize) + if component.values.count > 1 { + titleFrame.origin.x += floor(CGFloat(i) / CGFloat(component.values.count - 1) * titleAreaWidth) } - origin = origin.offsetBy(dx: 0.0, dy: size.height) + if titleFrame.minX < titleClippingSideInset { + titleFrame.origin.x = titleSideInset + } + if titleFrame.maxX > availableSize.width - titleClippingSideInset { + titleFrame.origin.x = availableSize.width - titleClippingSideInset - titleSize.width + } + if let titleView = title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + titleTransition.setPosition(view: titleView, position: titleFrame.center) + } + } + if self.titles.count > component.values.count { + for i in component.values.count ..< self.titles.count { + self.titles[i].view?.removeFromSuperview() + } + self.titles.removeLast(self.titles.count - component.values.count) } - let size = CGSize(width: availableSize.width, height: 180.0) - let contentSize = CGSize(width: availableSize.width, height: origin.y) - if contentSize != self.scrollView.contentSize { - self.scrollView.contentSize = contentSize + let sliderSize = self.slider.update( + transition: transition, + component: AnyComponent(SliderComponent( + valueCount: component.values.count, + value: component.selectedIndex, + trackBackgroundColor: component.theme.list.controlSecondaryColor, + trackForegroundColor: component.theme.list.itemAccentColor, + valueUpdated: { [weak self] value in + guard let self, let component = self.component else { + return + } + component.selectedIndexUpdated(value) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + let sliderFrame = CGRect(origin: CGPoint(x: sideInset, y: 36.0), size: sliderSize) + if let sliderView = self.slider.view { + if sliderView.superview == nil { + self.addSubview(sliderView) + } + transition.setFrame(view: sliderView, frame: sliderFrame) } - transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: size)) - - return size + + self.separatorInset = 16.0 + + return CGSize(width: availableSize.width, height: 88.0) } } @@ -383,78 +145,7 @@ final class AdjustmentsComponent: Component { return View(frame: CGRect()) } - public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} - -final class AdjustmentsScreenComponent: Component { - typealias EnvironmentType = Empty - - let toggleUneditedPreview: (Bool) -> Void - - init( - toggleUneditedPreview: @escaping (Bool) -> Void - ) { - self.toggleUneditedPreview = toggleUneditedPreview - } - - static func ==(lhs: AdjustmentsScreenComponent, rhs: AdjustmentsScreenComponent) -> Bool { - return true - } - - final class View: UIView { - enum Field { - case blacks - case shadows - case midtones - case highlights - case whites - } - - private var component: AdjustmentsScreenComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - super.init(frame: frame) - - let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress(_:))) - longPressGestureRecognizer.minimumPressDuration = 0.05 - self.addGestureRecognizer(longPressGestureRecognizer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc func handleLongPress(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let component = self.component else { - return - } - - switch gestureRecognizer.state { - case .began: - component.toggleUneditedPreview(true) - case .ended, .cancelled: - component.toggleUneditedPreview(false) - default: - break - } - } - - func update(component: AdjustmentsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - self.component = component - self.state = state - - return availableSize - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD index 419a284c2b..426f4b106b 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD @@ -34,6 +34,8 @@ swift_library( "//submodules/AppBundle", "//submodules/TelegramStringFormatting", "//submodules/UIKitRuntimeUtils", + "//submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen", + "//submodules/TelegramUI/Components/TimeSelectionActionSheet", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift index c061de51b5..d9fb9ed638 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift @@ -21,6 +21,7 @@ import Markdown import LocationUI import TelegramStringFormatting import PlainButtonComponent +import TimeSelectionActionSheet final class BusinessDaySetupScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift index 7aa0f33984..773cafe10f 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift @@ -20,6 +20,7 @@ import LottieComponent import Markdown import LocationUI import TelegramStringFormatting +import TimezoneSelectionScreen final class BusinessHoursSetupScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -75,7 +76,9 @@ final class BusinessHoursSetupScreenComponent: Component { private let subtitle = ComponentView() private let generalSection = ComponentView() private let daysSection = ComponentView() + private let timezoneSection = ComponentView() + private var ignoreScrolling: Bool = false private var isUpdating: Bool = false private var component: BusinessHoursSetupScreenComponent? @@ -84,6 +87,7 @@ final class BusinessHoursSetupScreenComponent: Component { private var showHours: Bool = false private var days: [Day] = [] + private var timezoneId: String override init(frame: CGRect) { self.scrollView = ScrollView() @@ -98,6 +102,8 @@ final class BusinessHoursSetupScreenComponent: Component { } self.scrollView.alwaysBounceVertical = true + self.timezoneId = TimeZone.current.identifier + super.init(frame: frame) self.scrollView.delegate = self @@ -126,7 +132,9 @@ final class BusinessHoursSetupScreenComponent: Component { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.updateScrolling(transition: .immediate) + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } } var scrolledUp = true @@ -320,6 +328,8 @@ final class BusinessHoursSetupScreenComponent: Component { contentHeight += generalSectionSize.height contentHeight += sectionSpacing + var daysContentHeight: CGFloat = 0.0 + var daysSectionItems: [AnyComponentWithIdentity] = [] for day in self.days { let dayIndex = daysSectionItems.count @@ -441,7 +451,7 @@ final class BusinessHoursSetupScreenComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) - let daysSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: daysSectionSize) + let daysSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + daysContentHeight), size: daysSectionSize) if let daysSectionView = self.daysSection.view { if daysSectionView.superview == nil { daysSectionView.layer.allowsGroupOpacity = true @@ -452,13 +462,80 @@ final class BusinessHoursSetupScreenComponent: Component { let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) alphaTransition.setAlpha(view: daysSectionView, alpha: self.showHours ? 1.0 : 0.0) } + daysContentHeight += daysSectionSize.height + daysContentHeight += sectionSpacing + + let timezoneSectionSize = self.timezoneSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Time Zone", //TODO:localize + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )), + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: TimeZone(identifier: self.timezoneId)?.localizedName(for: .shortStandard, locale: Locale.current) ?? self.timezoneId, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemSecondaryTextColor + )), + maximumNumberOfLines: 1 + )))), + accessory: .arrow, + action: { [weak self] _ in + guard let self, let component = self.component else { + return + } + var completed: ((String) -> Void)? + let controller = TimezoneSelectionScreen(context: component.context, completed: { timezoneId in + completed?(timezoneId) + }) + controller.navigationPresentation = .modal + self.environment?.controller()?.push(controller) + completed = { [weak self, weak controller] timezoneId in + guard let self else { + controller?.dismiss() + return + } + self.timezoneId = timezoneId + self.state?.updated(transition: .immediate) + controller?.dismiss() + } + } + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let timezoneSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + daysContentHeight), size: timezoneSectionSize) + if let timezoneSectionView = self.timezoneSection.view { + if timezoneSectionView.superview == nil { + self.scrollView.addSubview(timezoneSectionView) + } + transition.setFrame(view: timezoneSectionView, frame: timezoneSectionFrame) + let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) + alphaTransition.setAlpha(view: timezoneSectionView, alpha: self.showHours ? 1.0 : 0.0) + } + daysContentHeight += timezoneSectionSize.height + if self.showHours { - contentHeight += daysSectionSize.height + contentHeight += daysContentHeight } contentHeight += bottomContentInset contentHeight += environment.safeInsets.bottom + self.ignoreScrolling = true let previousBounds = self.scrollView.bounds let contentSize = CGSize(width: availableSize.width, height: contentHeight) @@ -472,6 +549,7 @@ final class BusinessHoursSetupScreenComponent: Component { if self.scrollView.scrollIndicatorInsets != scrollInsets { self.scrollView.scrollIndicatorInsets = scrollInsets } + self.ignoreScrolling = false if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds diff --git a/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift index 01b5e170c9..473a158b55 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift @@ -263,7 +263,11 @@ final class BusinessSetupScreenComponent: Component { icon: "Settings/Menu/Photos", title: "Quick Replies", subtitle: "Set up shortcuts with rich text and media to respond to messages faster.", - action: { + action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + environment.controller()?.push(component.context.sharedContext.makeQuickReplySetupScreen(context: component.context)) } )) items.append(Item( @@ -274,14 +278,18 @@ final class BusinessSetupScreenComponent: Component { guard let self, let component = self.component, let environment = self.environment else { return } - environment.controller()?.push(component.context.sharedContext.makeGreetingMessageSetupScreen(context: component.context)) + environment.controller()?.push(component.context.sharedContext.makeGreetingMessageSetupScreen(context: component.context, isAwayMode: false)) } )) items.append(Item( icon: "Settings/Menu/Trending", title: "Away Messages", subtitle: "Define messages that are automatically sent when you are off.", - action: { + action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + environment.controller()?.push(component.context.sharedContext.makeGreetingMessageSetupScreen(context: component.context, isAwayMode: true)) } )) items.append(Item( diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD index 2552d7c999..8ed69e1537 100644 --- a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD @@ -34,7 +34,17 @@ swift_library( "//submodules/AvatarNode", "//submodules/TelegramUI/Components/PlainButtonComponent", "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", + "//submodules/TelegramUI/Components/ListItemSliderSelectorComponent", "//submodules/ShimmerEffect", + "//submodules/ChatListUI", + "//submodules/MergeLists", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/ItemListPeerActionItem", + "//submodules/ItemListUI", + "//submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController", + "//submodules/DateSelectionUI", + "//submodules/TelegramStringFormatting", + "//submodules/TelegramUI/Components/TimeSelectionActionSheet", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageListItemComponent.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageListItemComponent.swift new file mode 100644 index 0000000000..1be73f5742 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageListItemComponent.swift @@ -0,0 +1,314 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ListSectionComponent +import TelegramPresentationData +import AppBundle +import ChatListUI +import AccountContext +import Postbox +import TelegramCore + +final class GreetingMessageListItemComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let accountPeer: EnginePeer + let message: EngineMessage + let count: Int + let action: (() -> Void)? + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + accountPeer: EnginePeer, + message: EngineMessage, + count: Int, + action: (() -> Void)? = nil + ) { + self.context = context + self.theme = theme + self.strings = strings + self.accountPeer = accountPeer + self.message = message + self.count = count + self.action = action + } + + static func ==(lhs: GreetingMessageListItemComponent, rhs: GreetingMessageListItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.accountPeer != rhs.accountPeer { + return false + } + if lhs.message != rhs.message { + return false + } + if lhs.count != rhs.count { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + + final class View: HighlightTrackingButton, ListSectionComponent.ChildView { + private var component: GreetingMessageListItemComponent? + private weak var componentState: EmptyComponentState? + + private var chatListPresentationData: ChatListPresentationData? + private var chatListNodeInteraction: ChatListNodeInteraction? + + private var itemNode: ListViewItemNode? + + var customUpdateIsHighlighted: ((Bool) -> Void)? + private(set) var separatorInset: CGFloat = 0.0 + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + self.internalHighligthedChanged = { [weak self] isHighlighted in + guard let self, let component = self.component, component.action != nil else { + return + } + if let customUpdateIsHighlighted = self.customUpdateIsHighlighted { + customUpdateIsHighlighted(isHighlighted) + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + self.component?.action?() + } + + func update(component: GreetingMessageListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let previousComponent = self.component + self.component = component + self.componentState = state + + self.isEnabled = component.action != nil + + let chatListPresentationData: ChatListPresentationData + if let current = self.chatListPresentationData, let previousComponent, previousComponent.theme === component.theme { + chatListPresentationData = current + } else { + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + chatListPresentationData = ChatListPresentationData( + theme: component.theme, + fontSize: presentationData.listsFontSize, + strings: component.strings, + dateTimeFormat: presentationData.dateTimeFormat, + nameSortOrder: presentationData.nameSortOrder, + nameDisplayOrder: presentationData.nameDisplayOrder, + disableAnimations: false + ) + self.chatListPresentationData = chatListPresentationData + } + + let chatListNodeInteraction: ChatListNodeInteraction + if let current = self.chatListNodeInteraction { + chatListNodeInteraction = current + } else { + chatListNodeInteraction = ChatListNodeInteraction( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + activateSearch: { + }, + peerSelected: { _, _, _, _ in + }, + disabledPeerSelected: { _, _, _ in + }, + togglePeerSelected: { _, _ in + }, + togglePeersSelection: { _, _ in + }, + additionalCategorySelected: { _ in + }, + messageSelected: { _, _, _, _ in + }, + groupSelected: { _ in + }, + addContact: { _ in + }, + setPeerIdWithRevealedOptions: { _, _ in + }, + setItemPinned: { _, _ in + }, + setPeerMuted: { _, _ in + }, + setPeerThreadMuted: { _, _, _ in + }, + deletePeer: { _, _ in + }, + deletePeerThread: { _, _ in + }, + setPeerThreadStopped: { _, _, _ in + }, + setPeerThreadPinned: { _, _, _ in + }, + setPeerThreadHidden: { _, _, _ in + }, + updatePeerGrouping: { _, _ in + }, + togglePeerMarkedUnread: { _, _ in + }, + toggleArchivedFolderHiddenByDefault: { + }, + toggleThreadsSelection: { _, _ in + }, + hidePsa: { _ in + }, + activateChatPreview: { _, _, _, _, _ in + }, + present: { _ in + }, + openForumThread: { _, _ in + }, + openStorageManagement: { + }, + openPasswordSetup: { + }, + openPremiumIntro: { + }, + openPremiumGift: { + }, + openActiveSessions: { + }, + performActiveSessionAction: { _, _ in + }, + openChatFolderUpdates: { + }, + hideChatFolderUpdates: { + }, + openStories: { _, _ in + }, + dismissNotice: { _ in + } + ) + self.chatListNodeInteraction = chatListNodeInteraction + } + + let chatListItem = ChatListItem( + presentationData: chatListPresentationData, + context: component.context, + chatListLocation: .chatList(groupId: .root), + filterData: nil, + index: EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: component.message.index)), + content: .peer(ChatListItemContent.PeerData( + messages: [component.message], + peer: EngineRenderedPeer(peer: component.accountPeer), + threadInfo: nil, + combinedReadState: nil, + isRemovedFromTotalUnreadCount: false, + presence: nil, + hasUnseenMentions: false, + hasUnseenReactions: false, + draftState: nil, + mediaDraftContentType: nil, + inputActivities: nil, + promoInfo: nil, + ignoreUnreadBadge: false, + displayAsMessage: false, + hasFailedMessages: false, + forumTopicData: nil, + topForumTopicItems: [], + autoremoveTimeout: nil, + storyState: nil, + requiresPremiumForMessaging: false, + displayAsTopicList: false, + tags: [], + customMessageListData: ChatListItemContent.CustomMessageListData( + commandPrefix: nil, + messageCount: component.count + ) + )), + editing: false, + hasActiveRevealControls: false, + selected: false, + header: nil, + enableContextActions: false, + hiddenOffset: false, + interaction: chatListNodeInteraction + ) + var itemNode: ListViewItemNode? + let params = ListViewItemLayoutParams(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, availableHeight: 1000.0) + if let current = self.itemNode { + itemNode = current + chatListItem.updateNode( + async: { f in f () }, + node: { + return current + }, + params: params, + previousItem: nil, + nextItem: nil, animation: .None, + completion: { layout, apply in + let nodeFrame = CGRect(origin: current.frame.origin, size: CGSize(width: layout.size.width, height: layout.size.height)) + + current.contentSize = layout.contentSize + current.insets = layout.insets + current.frame = nodeFrame + + apply(ListViewItemApply(isOnScreen: true)) + }) + } else { + var outItemNode: ListViewItemNode? + chatListItem.nodeConfiguredForParams( + async: { f in f() }, + params: params, + synchronousLoads: true, + previousItem: nil, + nextItem: nil, + completion: { node, apply in + outItemNode = node + apply().1(ListViewItemApply(isOnScreen: true)) + } + ) + itemNode = outItemNode + } + + let size = CGSize(width: availableSize.width, height: itemNode?.contentSize.height ?? 44.0) + + if self.itemNode !== itemNode { + self.itemNode?.removeFromSupernode() + + self.itemNode = itemNode + if let itemNode { + itemNode.isUserInteractionEnabled = false + self.addSubview(itemNode.view) + } + } + if let itemNode = self.itemNode { + itemNode.frame = CGRect(origin: CGPoint(), size: size) + } + + self.separatorInset = 76.0 + + return size + } + } + + 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/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupChatContents.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupChatContents.swift new file mode 100644 index 0000000000..a9a0f9b304 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupChatContents.swift @@ -0,0 +1,213 @@ +import Foundation +import UIKit +import SwiftSignalKit +import Postbox +import TelegramCore +import AccountContext + +final class GreetingMessageSetupChatContents: ChatCustomContentsProtocol { + private final class Impl { + let queue: Queue + let context: AccountContext + + private var messages: [Message] = [] + private var nextMessageId: Int32 = 1000 + let messagesPromise = Promise<[Message]>([]) + + private var nextGroupingId: UInt32 = 0 + private var groupingKeyToGroupId: [Int64: UInt32] = [:] + + init(queue: Queue, context: AccountContext, messages: [EngineMessage]) { + self.queue = queue + self.context = context + self.messages = messages.map { $0._asMessage() } + self.notifyMessagesUpdated() + + if let maxMessageId = messages.map(\.id).max() { + self.nextMessageId = maxMessageId.id + 1 + } + if let maxGroupingId = messages.compactMap(\.groupInfo?.stableId).max() { + self.nextGroupingId = maxGroupingId + 1 + } + } + + deinit { + } + + private func notifyMessagesUpdated() { + self.messages.sort(by: { $0.index > $1.index }) + self.messagesPromise.set(.single(self.messages)) + } + + func enqueueMessages(messages: [EnqueueMessage]) { + for message in messages { + switch message { + case let .message(text, attributes, _, mediaReference, _, _, _, localGroupingKey, correlationId, _): + let _ = attributes + let _ = mediaReference + let _ = correlationId + + let messageId = self.nextMessageId + self.nextMessageId += 1 + + var attributes: [MessageAttribute] = [] + attributes.append(OutgoingMessageInfoAttribute( + uniqueId: Int64.random(in: Int64.min ... Int64.max), + flags: [], + acknowledged: true, + correlationId: correlationId, + bubbleUpEmojiOrStickersets: [] + )) + + var media: [Media] = [] + if let mediaReference { + media.append(mediaReference.media) + } + + let mappedMessage = Message( + stableId: UInt32(messageId), + stableVersion: 0, + id: MessageId( + peerId: PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)), + namespace: Namespaces.Message.Local, + id: Int32(messageId) + ), + globallyUniqueId: nil, + groupingKey: localGroupingKey, + groupInfo: localGroupingKey.flatMap { value in + if let current = self.groupingKeyToGroupId[value] { + return MessageGroupInfo(stableId: current) + } else { + let groupId = self.nextGroupingId + self.nextGroupingId += 1 + self.groupingKeyToGroupId[value] = groupId + return MessageGroupInfo(stableId: groupId) + } + }, + threadId: nil, + timestamp: messageId, + flags: [], + tags: [], + globalTags: [], + localTags: [], + customTags: [], + forwardInfo: nil, + author: nil, + text: text, + attributes: attributes, + media: media, + peers: SimpleDictionary(), + associatedMessages: SimpleDictionary(), + associatedMessageIds: [], + associatedMedia: [:], + associatedThreadInfo: nil, + associatedStories: [:] + ) + self.messages.append(mappedMessage) + case .forward: + break + } + } + self.notifyMessagesUpdated() + } + + func deleteMessages(ids: [EngineMessage.Id]) { + self.messages = self.messages.filter({ !ids.contains($0.id) }) + self.notifyMessagesUpdated() + } + + func editMessage(id: EngineMessage.Id, text: String, media: RequestEditMessageMedia, entities: TextEntitiesMessageAttribute?, webpagePreviewAttribute: WebpagePreviewMessageAttribute?, disableUrlPreview: Bool) { + guard let index = self.messages.firstIndex(where: { $0.id == id }) else { + return + } + let originalMessage = self.messages[index] + + var mappedMedia = originalMessage.media + switch media { + case .keep: + break + case let .update(value): + mappedMedia = [value.media] + } + + var mappedAtrributes = originalMessage.attributes + mappedAtrributes.removeAll(where: { $0 is TextEntitiesMessageAttribute }) + if let entities { + mappedAtrributes.append(entities) + } + + let mappedMessage = Message( + stableId: originalMessage.stableId, + stableVersion: originalMessage.stableVersion + 1, + id: originalMessage.id, + globallyUniqueId: originalMessage.globallyUniqueId, + groupingKey: originalMessage.groupingKey, + groupInfo: originalMessage.groupInfo, + threadId: originalMessage.threadId, + timestamp: originalMessage.timestamp, + flags: originalMessage.flags, + tags: originalMessage.tags, + globalTags: originalMessage.globalTags, + localTags: originalMessage.localTags, + customTags: originalMessage.customTags, + forwardInfo: originalMessage.forwardInfo, + author: originalMessage.author, + text: text, + attributes: mappedAtrributes, + media: mappedMedia, + peers: originalMessage.peers, + associatedMessages: originalMessage.associatedMessages, + associatedMessageIds: originalMessage.associatedMessageIds, + associatedMedia: originalMessage.associatedMedia, + associatedThreadInfo: originalMessage.associatedThreadInfo, + associatedStories: originalMessage.associatedStories + ) + + self.messages[index] = mappedMessage + self.notifyMessagesUpdated() + } + } + + let kind: ChatCustomContentsKind + + var messages: Signal<[Message], NoError> { + return self.impl.signalWith({ impl, subscriber in + return impl.messagesPromise.get().start(next: subscriber.putNext) + }) + } + + var messageLimit: Int? { + return 20 + } + + private let queue: Queue + private let impl: QueueLocalObject + + init(context: AccountContext, messages: [EngineMessage], kind: ChatCustomContentsKind) { + self.kind = kind + + let queue = Queue() + self.queue = queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, context: context, messages: messages) + }) + } + + func enqueueMessages(messages: [EnqueueMessage]) { + self.impl.with { impl in + impl.enqueueMessages(messages: messages) + } + } + + func deleteMessages(ids: [EngineMessage.Id]) { + self.impl.with { impl in + impl.deleteMessages(ids: ids) + } + } + + func editMessage(id: EngineMessage.Id, text: String, media: RequestEditMessageMedia, entities: TextEntitiesMessageAttribute?, webpagePreviewAttribute: WebpagePreviewMessageAttribute?, disableUrlPreview: Bool) { + self.impl.with { impl in + impl.editMessage(id: id, text: text, media: media, entities: entities, webpagePreviewAttribute: webpagePreviewAttribute, disableUrlPreview: disableUrlPreview) + } + } +} diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift index aa0f455af2..5736c220f2 100644 --- a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift @@ -23,6 +23,11 @@ import LottieComponent import Markdown import PeerListItemComponent import AvatarNode +import ListItemSliderSelectorComponent +import DateSelectionUI +import PlainButtonComponent +import TelegramStringFormatting +import TimeSelectionActionSheet private let checkIcon: UIImage = { return generateImage(CGSize(width: 12.0, height: 10.0), rotatedContext: { size, context in @@ -41,17 +46,23 @@ final class GreetingMessageSetupScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext + let mode: GreetingMessageSetupScreen.Mode init( - context: AccountContext + context: AccountContext, + mode: GreetingMessageSetupScreen.Mode ) { self.context = context + self.mode = mode } static func ==(lhs: GreetingMessageSetupScreenComponent, rhs: GreetingMessageSetupScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } + if lhs.mode != rhs.mode { + return false + } return true } @@ -62,22 +73,6 @@ final class GreetingMessageSetupScreenComponent: Component { } } - private struct BotResolutionState: Equatable { - enum State: Equatable { - case searching - case notFound - case found(peer: EnginePeer, isInstalled: Bool) - } - - var query: String - var state: State - - init(query: String, state: State) { - self.query = query - self.state = state - } - } - private struct AdditionalPeerList { enum Category: Int { case newChats = 0 @@ -105,6 +100,12 @@ final class GreetingMessageSetupScreenComponent: Component { } } + private enum Schedule { + case always + case outsideBusinessHours + case custom + } + final class View: UIView, UIScrollViewDelegate { private let topOverscrollLayer = SimpleLayer() private let scrollView: ScrollView @@ -113,19 +114,27 @@ final class GreetingMessageSetupScreenComponent: Component { private let icon = ComponentView() private let subtitle = ComponentView() private let generalSection = ComponentView() + private let messagesSection = ComponentView() + private let scheduleSection = ComponentView() + private let customScheduleSection = ComponentView() private let accessSection = ComponentView() private let excludedSection = ComponentView() - private let permissionsSection = ComponentView() + private let periodSection = ComponentView() + private var ignoreScrolling: Bool = false private var isUpdating: Bool = false private var component: GreetingMessageSetupScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? - private var chevronImage: UIImage? - private var isOn: Bool = false + private var accountPeer: EnginePeer? + private var messages: [EngineMessage] = [] + + private var schedule: Schedule = .always + private var customScheduleStart: Date? + private var customScheduleEnd: Date? private var hasAccessToAllChatsByDefault: Bool = true private var additionalPeerList = AdditionalPeerList( @@ -135,6 +144,8 @@ final class GreetingMessageSetupScreenComponent: Component { private var replyToMessages: Bool = true + private var messagesDisposable: Disposable? + override init(frame: CGRect) { self.scrollView = ScrollView() self.scrollView.showsVerticalScrollIndicator = true @@ -161,6 +172,7 @@ final class GreetingMessageSetupScreenComponent: Component { } deinit { + self.messagesDisposable?.dispose() } func scrollToTop() { @@ -172,7 +184,9 @@ final class GreetingMessageSetupScreenComponent: Component { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.updateScrolling(transition: .immediate) + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } } var scrolledUp = true @@ -332,12 +346,140 @@ final class GreetingMessageSetupScreenComponent: Component { self.environment?.controller()?.push(controller) } + private func openMessageList() { + guard let component = self.component else { + return + } + let contents = GreetingMessageSetupChatContents( + context: component.context, + messages: self.messages, + kind: component.mode == .away ? .awayMessageInput : .greetingMessageInput + ) + let chatController = component.context.sharedContext.makeChatController( + context: component.context, + chatLocation: .customChatContents, + subject: .customChatContents(contents: contents), + botStart: nil, + mode: .standard(.default) + ) + chatController.navigationPresentation = .modal + self.environment?.controller()?.push(chatController) + self.messagesDisposable?.dispose() + self.messagesDisposable = (contents.messages + |> deliverOnMainQueue).startStrict(next: { [weak self] messages in + guard let self else { + return + } + let messages = messages.map(EngineMessage.init) + if self.messages != messages { + self.messages = messages + self.state?.updated(transition: .immediate) + } + }) + } + + private func openCustomScheduleDateSetup(isStartTime: Bool, isDate: Bool) { + guard let component = self.component else { + return + } + + let currentValue: Date = (isStartTime ? self.customScheduleStart : self.customScheduleEnd) ?? Date() + + if isDate { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: currentValue) + guard let clippedDate = calendar.date(from: components) else { + return + } + + let controller = DateSelectionActionSheetController( + context: component.context, + title: nil, + currentValue: Int32(clippedDate.timeIntervalSince1970), + minimumDate: nil, + maximumDate: nil, + emptyTitle: nil, + applyValue: { [weak self] value in + guard let self else { + return + } + guard let value else { + return + } + let updatedDate = Date(timeIntervalSince1970: Double(value)) + let calendar = Calendar.current + var updatedComponents = calendar.dateComponents([.year, .month, .day], from: updatedDate) + let currentComponents = calendar.dateComponents([.hour, .minute], from: currentValue) + updatedComponents.hour = currentComponents.hour + updatedComponents.minute = currentComponents.minute + guard let updatedClippedDate = calendar.date(from: updatedComponents) else { + return + } + + if isStartTime { + self.customScheduleStart = updatedClippedDate + } else { + self.customScheduleEnd = updatedClippedDate + } + self.state?.updated(transition: .immediate) + } + ) + self.environment?.controller()?.present(controller, in: .window(.root)) + } else { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: currentValue) + let hour = components.hour ?? 0 + let minute = components.minute ?? 0 + + let controller = TimeSelectionActionSheet(context: component.context, currentValue: Int32(hour * 60 * 60 + minute * 60), applyValue: { [weak self] value in + guard let self else { + return + } + guard let value else { + return + } + + let updatedHour = value / (60 * 60) + let updatedMinute = (value % (60 * 60)) / 60 + + let calendar = Calendar.current + var updatedComponents = calendar.dateComponents([.year, .month, .day], from: currentValue) + updatedComponents.hour = Int(updatedHour) + updatedComponents.minute = Int(updatedMinute) + + guard let updatedClippedDate = calendar.date(from: updatedComponents) else { + return + } + + if isStartTime { + self.customScheduleStart = updatedClippedDate + } else { + self.customScheduleEnd = updatedClippedDate + } + self.state?.updated(transition: .immediate) + }) + self.environment?.controller()?.present(controller, in: .window(.root)) + } + } + func update(component: GreetingMessageSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } + if self.component == nil { + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self else { + return + } + self.accountPeer = peer + }) + } + let environment = environment[EnvironmentType.self].value let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment @@ -357,7 +499,7 @@ final class GreetingMessageSetupScreenComponent: Component { let navigationTitleSize = self.navigationTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "Greeting Message", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + text: .plain(NSAttributedString(string: component.mode == .greeting ? "Greeting Message" : "Away Message", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center )), environment: {}, @@ -387,7 +529,7 @@ final class GreetingMessageSetupScreenComponent: Component { let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(LottieComponent( - content: LottieComponent.AppBundleContent(name: "HandWaveEmoji"), + content: LottieComponent.AppBundleContent(name: component.mode == .greeting ? "HandWaveEmoji" : "ZzzEmoji"), loop: true )), environment: {}, @@ -405,7 +547,7 @@ final class GreetingMessageSetupScreenComponent: Component { contentHeight += 129.0 //TODO:localize - let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Greet customers when they message you the first time or after a period of no activity.", attributes: MarkdownAttributes( + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(component.mode == .greeting ? "Greet customers when they message you the first time or after a period of no activity." : "Automatically reply with a message when you are away.", attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), @@ -413,12 +555,6 @@ final class GreetingMessageSetupScreenComponent: Component { return ("URL", "") }), textAlignment: .center )) - if self.chevronImage == nil { - self.chevronImage = UIImage(bundleImageName: "Settings/TextArrowRight") - } - if let range = subtitleString.string.range(of: ">"), let chevronImage = self.chevronImage { - subtitleString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: subtitleString.string)) - } //TODO:localize let subtitleSize = self.subtitle.update( @@ -463,7 +599,7 @@ final class GreetingMessageSetupScreenComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Send Greeting Message", + string: component.mode == .greeting ? "Send Greeting Message" : "Send Away Message", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), @@ -504,6 +640,279 @@ final class GreetingMessageSetupScreenComponent: Component { var otherSectionsHeight: CGFloat = 0.0 + //TODO:localize + var messagesSectionItems: [AnyComponentWithIdentity] = [] + if let topMessage = self.messages.first { + if let accountPeer = self.accountPeer { + messagesSectionItems.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(GreetingMessageListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + accountPeer: accountPeer, + message: topMessage, + count: self.messages.count, + action: { [weak self] in + guard let self else { + return + } + self.openMessageList() + } + )))) + } + } else { + messagesSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.mode == .greeting ? "Create a Greeting Message" : "Create an Away Message", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemAccentColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + name: "Chat List/ComposeIcon", + tintColor: environment.theme.list.itemAccentColor + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.openMessageList() + } + )))) + } + let messagesSectionSize = self.messagesSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.mode == .greeting ? (self.messages.count > 1 ? "GREETING MESSAGES" : "GREETING MESSAGE") : (self.messages.count > 1 ? "AWAY MESSAGES" : "AWAY MESSAGE"), + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: messagesSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let messagesSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: messagesSectionSize) + if let messagesSectionView = self.messagesSection.view { + if messagesSectionView.superview == nil { + messagesSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(messagesSectionView) + } + transition.setFrame(view: messagesSectionView, frame: messagesSectionFrame) + alphaTransition.setAlpha(view: messagesSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += messagesSectionSize.height + otherSectionsHeight += sectionSpacing + + if case .away = component.mode { + //TODO:localize + var scheduleSectionItems: [AnyComponentWithIdentity] = [] + for i in 0 ..< 3 { + let title: String + let schedule: Schedule + switch i { + case 0: + title = "Always Send" + schedule = .always + case 1: + title = "Outside of Business Hours" + schedule = .outsideBusinessHours + default: + title = "Custom Schedule" + schedule = .custom + } + let isSelected = self.schedule == schedule + scheduleSectionItems.append(AnyComponentWithIdentity(id: scheduleSectionItems.count, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + image: checkIcon, + tintColor: !isSelected ? .clear : environment.theme.list.itemAccentColor, + contentMode: .center + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + + if self.schedule != schedule { + self.schedule = schedule + self.state?.updated(transition: .immediate) + } + } + )))) + } + let scheduleSectionSize = self.scheduleSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "SCHEDULE", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: scheduleSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let scheduleSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: scheduleSectionSize) + if let scheduleSectionView = self.scheduleSection.view { + if scheduleSectionView.superview == nil { + scheduleSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(scheduleSectionView) + } + transition.setFrame(view: scheduleSectionView, frame: scheduleSectionFrame) + alphaTransition.setAlpha(view: scheduleSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += scheduleSectionSize.height + otherSectionsHeight += sectionSpacing + + var customScheduleSectionsHeight: CGFloat = 0.0 + var customScheduleSectionItems: [AnyComponentWithIdentity] = [] + for i in 0 ..< 2 { + let title: String + let itemDate: Date? + let isStartTime: Bool + switch i { + case 0: + title = "Start Time" + itemDate = self.customScheduleStart + isStartTime = true + default: + title = "End Time" + itemDate = self.customScheduleEnd + isStartTime = false + } + + var icon: ListActionItemComponent.Icon? + if let itemDate { + let calendar = Calendar.current + let hours = calendar.component(.hour, from: itemDate) + let minutes = calendar.component(.minute, from: itemDate) + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + let timeText = stringForShortTimestamp(hours: Int32(hours), minutes: Int32(minutes), dateTimeFormat: presentationData.dateTimeFormat) + + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .none + dateFormatter.dateStyle = .medium + let dateText = stringForCompactDate(timestamp: Int32(itemDate.timeIntervalSince1970), strings: environment.strings, dateTimeFormat: presentationData.dateTimeFormat) + + icon = ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(HStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: dateText, font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + background: AnyComponent(RoundedRectangle(color: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), cornerRadius: 6.0)), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 7.0, left: 8.0, bottom: 7.0, right: 8.0), + action: { [weak self] in + guard let self else { + return + } + self.openCustomScheduleDateSetup(isStartTime: isStartTime, isDate: true) + }, + animateAlpha: true, + animateScale: false + ))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: timeText, font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + background: AnyComponent(RoundedRectangle(color: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), cornerRadius: 6.0)), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 7.0, left: 8.0, bottom: 7.0, right: 8.0), + action: { [weak self] in + guard let self else { + return + } + self.openCustomScheduleDateSetup(isStartTime: isStartTime, isDate: false) + }, + animateAlpha: true, + animateScale: false + ))) + ], spacing: 4.0))), insets: .custom(UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0)), allowUserInteraction: true) + } + + customScheduleSectionItems.append(AnyComponentWithIdentity(id: customScheduleSectionItems.count, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + icon: icon, + accessory: nil, + action: itemDate != nil ? nil : { [weak self] _ in + guard let self else { + return + } + self.openCustomScheduleDateSetup(isStartTime: isStartTime, isDate: true) + } + )))) + } + let customScheduleSectionSize = self.customScheduleSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: customScheduleSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let customScheduleSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight + customScheduleSectionsHeight), size: customScheduleSectionSize) + if let customScheduleSectionView = self.customScheduleSection.view { + if customScheduleSectionView.superview == nil { + customScheduleSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(customScheduleSectionView) + } + transition.setFrame(view: customScheduleSectionView, frame: customScheduleSectionFrame) + alphaTransition.setAlpha(view: customScheduleSectionView, alpha: (self.isOn && self.schedule == .custom) ? 1.0 : 0.0) + } + customScheduleSectionsHeight += customScheduleSectionSize.height + customScheduleSectionsHeight += sectionSpacing + + if self.schedule == .custom { + otherSectionsHeight += customScheduleSectionsHeight + } + } + //TODO:localize let accessSectionSize = self.accessSection.update( transition: transition, @@ -700,7 +1109,7 @@ final class GreetingMessageSetupScreenComponent: Component { )), footer: AnyComponent(MultilineTextComponent( text: .markdown( - text: self.hasAccessToAllChatsByDefault ? "Select chats or entire chat categories which the bot **WILL NOT** have access to." : "Select chats or entire chat categories which the bot **WILL** have access to.", + text: component.mode == .greeting ? "Choose chats or entire chat categories for sending a greeting message." : "Choose chats or entire chat categories for sending an away message.", attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), @@ -729,65 +1138,61 @@ final class GreetingMessageSetupScreenComponent: Component { otherSectionsHeight += excludedSectionSize.height otherSectionsHeight += sectionSpacing - //TODO:localize - /*let permissionsSectionSize = self.permissionsSection.update( - transition: transition, - component: AnyComponent(ListSectionComponent( - theme: environment.theme, - header: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: "BOT PERMISSIONS", - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor + if case .greeting = component.mode { + let periodSectionSize = self.periodSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "PERIOD OF NO ACTIVITY", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 )), - maximumNumberOfLines: 0 - )), - footer: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: "The bot will be able to view all new incoming messages, but not the messages that had been sent before you added the bot.", - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor + footer: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Choose how many days should pass after your last interaction with a recipient to send them the greeting in response to their message.", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 )), - maximumNumberOfLines: 0 - )), - items: [ - AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: "Reply to Messages", - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - ))), - ], alignment: .left, spacing: 2.0)), - accessory: .toggle(ListActionItemComponent.Toggle(style: .icons, isOn: self.replyToMessages, action: { [weak self] _ in - guard let self else { - return + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemSliderSelectorComponent( + theme: environment.theme, + values: [ + "7 days", + "14 days", + "21 days", + "28 days" + ], + selectedIndex: 0, + selectedIndexUpdated: { [weak self] index in + guard let self else { + return + } + let _ = self } - self.replyToMessages = !self.replyToMessages - self.state?.updated(transition: .spring(duration: 0.4)) - })), - action: nil - ))) - ] - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) - ) - let permissionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: permissionsSectionSize) - if let permissionsSectionView = self.permissionsSection.view { - if permissionsSectionView.superview == nil { - permissionsSectionView.layer.allowsGroupOpacity = true - self.scrollView.addSubview(permissionsSectionView) + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let periodSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: periodSectionSize) + if let periodSectionView = self.periodSection.view { + if periodSectionView.superview == nil { + periodSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(periodSectionView) + } + transition.setFrame(view: periodSectionView, frame: periodSectionFrame) + alphaTransition.setAlpha(view: periodSectionView, alpha: self.isOn ? 1.0 : 0.0) } - transition.setFrame(view: permissionsSectionView, frame: permissionsSectionFrame) - - alphaTransition.setAlpha(view: permissionsSectionView, alpha: self.isOn ? 1.0 : 0.0) + otherSectionsHeight += periodSectionSize.height + otherSectionsHeight += sectionSpacing } - otherSectionsHeight += permissionsSectionSize.height*/ if self.isOn { contentHeight += otherSectionsHeight @@ -799,6 +1204,7 @@ final class GreetingMessageSetupScreenComponent: Component { let previousBounds = self.scrollView.bounds let contentSize = CGSize(width: availableSize.width, height: contentHeight) + self.ignoreScrolling = true if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) } @@ -809,6 +1215,7 @@ final class GreetingMessageSetupScreenComponent: Component { if self.scrollView.scrollIndicatorInsets != scrollInsets { self.scrollView.scrollIndicatorInsets = scrollInsets } + self.ignoreScrolling = false if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds @@ -836,13 +1243,19 @@ final class GreetingMessageSetupScreenComponent: Component { } public final class GreetingMessageSetupScreen: ViewControllerComponentContainer { + public enum Mode { + case greeting + case away + } + private let context: AccountContext - public init(context: AccountContext) { + public init(context: AccountContext, mode: Mode) { self.context = context super.init(context: context, component: GreetingMessageSetupScreenComponent( - context: context + context: context, + mode: mode ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) let presentationData = context.sharedContext.currentPresentationData.with { $0 } diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplyEmptyStateComponent.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplyEmptyStateComponent.swift new file mode 100644 index 0000000000..d2572d0be4 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplyEmptyStateComponent.swift @@ -0,0 +1,181 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import AppBundle +import ButtonComponent +import MultilineTextComponent +import BalancedTextComponent + +final class QuickReplyEmptyStateComponent: Component { + let theme: PresentationTheme + let strings: PresentationStrings + let insets: UIEdgeInsets + let action: () -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + insets: UIEdgeInsets, + action: @escaping () -> Void + ) { + self.theme = theme + self.strings = strings + self.insets = insets + self.action = action + } + + static func ==(lhs: QuickReplyEmptyStateComponent, rhs: QuickReplyEmptyStateComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.insets != rhs.insets { + return false + } + return true + } + + final class View: UIView { + private let icon = ComponentView() + private let title = ComponentView() + private let text = ComponentView() + private let button = ComponentView() + + private var component: QuickReplyEmptyStateComponent? + private weak var componentState: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: QuickReplyEmptyStateComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let previousComponent = self.component + self.component = component + self.componentState = state + + let _ = previousComponent + + let iconTitleSpacing: CGFloat = 10.0 + let titleTextSpacing: CGFloat = 8.0 + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "📝", font: Font.semibold(90.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + //TODO:localize + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "No Quick Replies", font: Font.semibold(17.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 100.0) + ) + + let textSize = self.text.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(NSAttributedString(string: "Set up shortcuts with rich text and media to respond to messages faster.", font: Font.regular(15.0), textColor: component.theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 20 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 100.0) + ) + + let centralContentsHeight: CGFloat = iconSize.height + iconTitleSpacing + titleSize.height + titleTextSpacing + var centralContentsY: CGFloat = component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - centralContentsHeight) * 0.5) + + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: centralContentsY), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + self.addSubview(iconView) + } + transition.setFrame(view: iconView, frame: iconFrame) + } + centralContentsY += iconSize.height + iconTitleSpacing + + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: centralContentsY), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + transition.setPosition(view: titleView, position: titleFrame.center) + } + centralContentsY += titleSize.height + titleTextSpacing + + let textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - textSize.width) * 0.5), y: centralContentsY), size: textSize) + if let textView = self.text.view { + if textView.superview == nil { + self.addSubview(textView) + } + textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) + transition.setPosition(view: textView, position: textFrame.center) + } + + let buttonSize = self.button.update( + transition: transition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: component.theme.list.itemCheckColors.fillColor, + foreground: component.theme.list.itemCheckColors.foregroundColor, + pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(ButtonTextContentComponent( + text: "Add Quick Reply", + badge: 0, + textColor: component.theme.list.itemCheckColors.foregroundColor, + badgeBackground: component.theme.list.itemCheckColors.foregroundColor, + badgeForeground: component.theme.list.itemCheckColors.fillColor + )) + ), + isEnabled: true, + displaysProgress: false, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.action() + } + )), + environment: {}, + containerSize: CGSize(width: min(availableSize.width - 16.0 * 2.0, 280.0), height: 50.0) + ) + let buttonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - buttonSize.width) * 0.5), y: availableSize.height - component.insets.bottom - 8.0 - buttonSize.height), size: buttonSize) + if let buttonView = self.button.view { + if buttonView.superview == nil { + self.addSubview(buttonView) + } + transition.setFrame(view: buttonView, frame: buttonFrame) + } + + 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) + } +} diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplySetupScreen.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplySetupScreen.swift new file mode 100644 index 0000000000..0b0303fb1c --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/QuickReplySetupScreen.swift @@ -0,0 +1,563 @@ +import Foundation +import UIKit +import Photos +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MergeLists +import ComponentDisplayAdapters +import ItemListPeerActionItem +import ItemListUI +import ChatListUI +import QuickReplyNameAlertController + +final class QuickReplySetupScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: QuickReplySetupScreenComponent, rhs: QuickReplySetupScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + + return true + } + + private struct ShortcutItem: Equatable { + var shortcut: String + var messages: [EngineMessage] + + init(shortcut: String, messages: [EngineMessage]) { + self.shortcut = shortcut + self.messages = messages + } + } + + private enum ContentEntry: Comparable, Identifiable { + enum Id: Hashable { + case add + case item(String) + } + + var stableId: Id { + switch self { + case .add: + return .add + case let .item(item, _, _): + return .item(item.shortcut) + } + } + + case add + case item(item: ShortcutItem, accountPeer: EnginePeer, sortIndex: Int) + + static func <(lhs: ContentEntry, rhs: ContentEntry) -> Bool { + switch lhs { + case .add: + return false + case let .item(lhsItem, _, lhsSortIndex): + switch rhs { + case .add: + return false + case let .item(rhsItem, _, rhsSortIndex): + if lhsSortIndex != rhsSortIndex { + return lhsSortIndex < rhsSortIndex + } + return lhsItem.shortcut < rhsItem.shortcut + } + } + } + + func item(listNode: ContentListNode) -> ListViewItem { + switch self { + case .add: + //TODO:localize + return ItemListPeerActionItem( + presentationData: ItemListPresentationData(listNode.presentationData), + icon: PresentationResourcesItemList.plusIconImage(listNode.presentationData.theme), + iconSignal: nil, + title: "New Quick Reply", + additionalBadgeIcon: nil, + alwaysPlain: true, + hasSeparator: true, + sectionId: 0, + height: .generic, + color: .accent, + editing: false, + action: { [weak listNode] in + guard let listNode, let parentView = listNode.parentView else { + return + } + parentView.openQuickReplyChat(shortcut: nil) + } + ) + case let .item(item, accountPeer, _): + let chatListNodeInteraction = ChatListNodeInteraction( + context: listNode.context, + animationCache: listNode.context.animationCache, + animationRenderer: listNode.context.animationRenderer, + activateSearch: { + }, + peerSelected: { [weak listNode] _, _, _, _ in + guard let listNode, let parentView = listNode.parentView else { + return + } + parentView.openQuickReplyChat(shortcut: item.shortcut) + }, + disabledPeerSelected: { _, _, _ in + }, + togglePeerSelected: { _, _ in + }, + togglePeersSelection: { _, _ in + }, + additionalCategorySelected: { _ in + }, + messageSelected: { [weak listNode] _, _, _, _ in + guard let listNode, let parentView = listNode.parentView else { + return + } + parentView.openQuickReplyChat(shortcut: item.shortcut) + }, + groupSelected: { _ in + }, + addContact: { _ in + }, + setPeerIdWithRevealedOptions: { _, _ in + }, + setItemPinned: { _, _ in + }, + setPeerMuted: { _, _ in + }, + setPeerThreadMuted: { _, _, _ in + }, + deletePeer: { _, _ in + }, + deletePeerThread: { _, _ in + }, + setPeerThreadStopped: { _, _, _ in + }, + setPeerThreadPinned: { _, _, _ in + }, + setPeerThreadHidden: { _, _, _ in + }, + updatePeerGrouping: { _, _ in + }, + togglePeerMarkedUnread: { _, _ in + }, + toggleArchivedFolderHiddenByDefault: { + }, + toggleThreadsSelection: { _, _ in + }, + hidePsa: { _ in + }, + activateChatPreview: { _, _, _, _, _ in + }, + present: { _ in + }, + openForumThread: { _, _ in + }, + openStorageManagement: { + }, + openPasswordSetup: { + }, + openPremiumIntro: { + }, + openPremiumGift: { + }, + openActiveSessions: { + }, + performActiveSessionAction: { _, _ in + }, + openChatFolderUpdates: { + }, + hideChatFolderUpdates: { + }, + openStories: { _, _ in + }, + dismissNotice: { _ in + } + ) + + let presentationData = listNode.context.sharedContext.currentPresentationData.with({ $0 }) + let chatListPresentationData = ChatListPresentationData( + theme: presentationData.theme, + fontSize: presentationData.listsFontSize, + strings: presentationData.strings, + dateTimeFormat: presentationData.dateTimeFormat, + nameSortOrder: presentationData.nameSortOrder, + nameDisplayOrder: presentationData.nameDisplayOrder, + disableAnimations: false + ) + + return ChatListItem( + presentationData: chatListPresentationData, + context: listNode.context, + chatListLocation: .chatList(groupId: .root), + filterData: nil, + index: EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: MessageIndex(id: MessageId(peerId: listNode.context.account.peerId, namespace: 0, id: 0), timestamp: 0))), + content: .peer(ChatListItemContent.PeerData( + messages: item.messages.first.flatMap({ [$0] }) ?? [], + peer: EngineRenderedPeer(peer: accountPeer), + threadInfo: nil, + combinedReadState: nil, + isRemovedFromTotalUnreadCount: false, + presence: nil, + hasUnseenMentions: false, + hasUnseenReactions: false, + draftState: nil, + mediaDraftContentType: nil, + inputActivities: nil, + promoInfo: nil, + ignoreUnreadBadge: false, + displayAsMessage: false, + hasFailedMessages: false, + forumTopicData: nil, + topForumTopicItems: [], + autoremoveTimeout: nil, + storyState: nil, + requiresPremiumForMessaging: false, + displayAsTopicList: false, + tags: [], + customMessageListData: ChatListItemContent.CustomMessageListData( + commandPrefix: "/\(item.shortcut)", + messageCount: nil + ) + )), + editing: false, + hasActiveRevealControls: false, + selected: false, + header: nil, + enableContextActions: false, + hiddenOffset: false, + interaction: chatListNodeInteraction + ) + } + } + } + + private final class ContentListNode: ListView { + weak var parentView: View? + let context: AccountContext + var presentationData: PresentationData + private var currentEntries: [ContentEntry] = [] + + init(parentView: View, context: AccountContext) { + self.parentView = parentView + self.context = context + self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + + super.init() + } + + func update(size: CGSize, insets: UIEdgeInsets, transition: Transition) { + let (listViewDuration, listViewCurve) = listViewAnimationDurationAndCurve(transition: transition.containedViewLayoutTransition) + self.transaction( + deleteIndices: [], + insertIndicesAndItems: [], + updateIndicesAndItems: [], + options: [.Synchronous, .LowLatency], + additionalScrollDistance: 0.0, + updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: listViewDuration, curve: listViewCurve), + updateOpaqueState: nil + ) + } + + func setEntries(entries: [ContentEntry], animated: Bool) { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.currentEntries, rightList: entries) + self.currentEntries = entries + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } + + self.transaction( + deleteIndices: deletions, + insertIndicesAndItems: insertions, + updateIndicesAndItems: updates, + options: [.Synchronous, .LowLatency], + scrollToItem: nil, + stationaryItemRange: nil, + updateOpaqueState: nil, + completion: { _ in + } + ) + } + } + + final class View: UIView { + private var emptyState: ComponentView? + private var contentListNode: ContentListNode? + + private var isUpdating: Bool = false + + private var component: QuickReplySetupScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var items: [ShortcutItem] = [] + private var messagesDisposable: Disposable? + + private var accountPeer: EnginePeer? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.messagesDisposable?.dispose() + } + + func scrollToTop() { + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func openQuickReplyChat(shortcut: String?) { + guard let component = self.component else { + return + } + + if let shortcut { + let contents = GreetingMessageSetupChatContents( + context: component.context, + messages: self.items.first(where: { $0.shortcut == shortcut })?.messages ?? [], + kind: .quickReplyMessageInput(shortcut: shortcut) + ) + let chatController = component.context.sharedContext.makeChatController( + context: component.context, + chatLocation: .customChatContents, + subject: .customChatContents(contents: contents), + botStart: nil, + mode: .standard(.default) + ) + chatController.navigationPresentation = .modal + self.environment?.controller()?.push(chatController) + self.messagesDisposable?.dispose() + self.messagesDisposable = (contents.messages + |> deliverOnMainQueue).startStrict(next: { [weak self] messages in + guard let self else { + return + } + let messages = messages.map(EngineMessage.init) + + if messages.isEmpty { + if let index = self.items.firstIndex(where: { $0.shortcut == shortcut }) { + self.items.remove(at: index) + } + } else { + if let index = self.items.firstIndex(where: { $0.shortcut == shortcut }) { + self.items[index].messages = messages + } else { + self.items.insert(ShortcutItem( + shortcut: shortcut, + messages: messages + ), at: 0) + } + } + + self.state?.updated(transition: .immediate) + }) + } else { + var completion: ((String?) -> Void)? + let alertController = quickReplyNameAlertController( + context: component.context, + text: "New Quick Reply", + subtext: "Add a shortcut for your quick reply.", + value: "", + characterLimit: 32, + apply: { value in + completion?(value) + } + ) + completion = { [weak self, weak alertController] value in + guard let self else { + alertController?.dismissAnimated() + return + } + if let value, !value.isEmpty { + alertController?.dismissAnimated() + self.openQuickReplyChat(shortcut: value) + } + } + self.environment?.controller()?.present(alertController, in: .window(.root)) + } + + self.contentListNode?.clearHighlightAnimated(true) + } + + func update(component: QuickReplySetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + if self.component == nil { + let _ = (component.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: component.context.account.peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self else { + return + } + self.accountPeer = peer + }) + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + let alphaTransition: Transition = transition.animation.isImmediate ? transition : transition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) + let _ = alphaTransition + + if themeUpdated { + self.backgroundColor = environment.theme.list.plainBackgroundColor + } + + if self.items.isEmpty { + let emptyState: ComponentView + var emptyStateTransition = transition + if let current = self.emptyState { + emptyState = current + } else { + emptyState = ComponentView() + self.emptyState = emptyState + emptyStateTransition = emptyStateTransition.withAnimation(.none) + } + + let emptyStateFrame = CGRect(origin: CGPoint(x: 0.0, y: environment.navigationHeight), size: CGSize(width: availableSize.width, height: availableSize.height - environment.navigationHeight)) + let _ = emptyState.update( + transition: emptyStateTransition, + component: AnyComponent(QuickReplyEmptyStateComponent( + theme: environment.theme, + strings: environment.strings, + insets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: environment.safeInsets.bottom, right: environment.safeInsets.right), + action: { [weak self] in + guard let self else { + return + } + self.openQuickReplyChat(shortcut: nil) + } + )), + environment: {}, + containerSize: emptyStateFrame.size + ) + if let emptyStateView = emptyState.view { + if emptyStateView.superview == nil { + self.addSubview(emptyStateView) + } + emptyStateTransition.setFrame(view: emptyStateView, frame: emptyStateFrame) + } + } else { + if let emptyState = self.emptyState { + self.emptyState = nil + emptyState.view?.removeFromSuperview() + } + } + + let contentListNode: ContentListNode + if let current = self.contentListNode { + contentListNode = current + } else { + contentListNode = ContentListNode(parentView: self, context: component.context) + self.contentListNode = contentListNode + self.addSubview(contentListNode.view) + } + + transition.setFrame(view: contentListNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + contentListNode.update(size: availableSize, insets: UIEdgeInsets(top: environment.navigationHeight, left: environment.safeInsets.left, bottom: environment.safeInsets.bottom, right: environment.safeInsets.right), transition: transition) + + var entries: [ContentEntry] = [] + if let accountPeer = self.accountPeer { + entries.append(.add) + for item in self.items { + entries.append(.item(item: item, accountPeer: accountPeer, sortIndex: entries.count)) + } + } + contentListNode.setEntries(entries: entries, animated: false) + + contentListNode.isHidden = self.items.isEmpty + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + 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 final class QuickReplySetupScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext) { + self.context = context + + super.init(context: context, component: QuickReplySetupScreenComponent( + context: context + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + //TODO:localize + self.title = "Quick Replies" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? QuickReplySetupScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? QuickReplySetupScreenComponent.View else { + return true + } + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/BUILD b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/BUILD new file mode 100644 index 0000000000..a9f96d6606 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/BUILD @@ -0,0 +1,28 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "QuickReplyNameAlertController", + module_name = "QuickReplyNameAlertController", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/AccountContext:AccountContext", + "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/ComponentFlow", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramUI/Components/EmojiStatusComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift new file mode 100644 index 0000000000..f5471769eb --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift @@ -0,0 +1,508 @@ +import Foundation +import UIKit +import SwiftSignalKit +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import TelegramPresentationData +import AccountContext +import ComponentFlow +import MultilineTextComponent +import BalancedTextComponent +import EmojiStatusComponent + +private final class PromptInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegate { + private var theme: PresentationTheme + private let backgroundNode: ASImageNode + private let textInputNode: EditableTextNode + private let placeholderNode: ASTextNode + private let characterLimitView = ComponentView() + + private let characterLimit: Int + + var updateHeight: (() -> Void)? + var complete: (() -> Void)? + var textChanged: ((String) -> Void)? + + private let backgroundInsets = UIEdgeInsets(top: 8.0, left: 16.0, bottom: 15.0, right: 16.0) + private let inputInsets: UIEdgeInsets + + var text: String { + get { + return self.textInputNode.attributedText?.string ?? "" + } + set { + self.textInputNode.attributedText = NSAttributedString(string: newValue, font: Font.regular(13.0), textColor: self.theme.actionSheet.inputTextColor) + self.placeholderNode.isHidden = !newValue.isEmpty + } + } + + var placeholder: String = "" { + didSet { + self.placeholderNode.attributedText = NSAttributedString(string: self.placeholder, font: Font.regular(13.0), textColor: self.theme.actionSheet.inputPlaceholderColor) + } + } + + init(theme: PresentationTheme, placeholder: String, characterLimit: Int) { + self.theme = theme + self.characterLimit = characterLimit + + self.inputInsets = UIEdgeInsets(top: 9.0, left: 6.0, bottom: 9.0, right: 16.0) + + self.backgroundNode = ASImageNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.displayWithoutProcessing = true + self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 16.0, color: theme.actionSheet.inputHollowBackgroundColor, strokeColor: theme.actionSheet.inputBorderColor, strokeWidth: 1.0) + + self.textInputNode = EditableTextNode() + self.textInputNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(13.0), NSAttributedString.Key.foregroundColor.rawValue: theme.actionSheet.inputTextColor] + self.textInputNode.clipsToBounds = true + self.textInputNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0) + self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: self.inputInsets.left, bottom: self.inputInsets.bottom, right: self.inputInsets.right) + self.textInputNode.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance + self.textInputNode.keyboardType = .default + self.textInputNode.autocapitalizationType = .none + self.textInputNode.returnKeyType = .done + self.textInputNode.autocorrectionType = .no + self.textInputNode.tintColor = theme.actionSheet.controlAccentColor + + self.placeholderNode = ASTextNode() + self.placeholderNode.isUserInteractionEnabled = false + self.placeholderNode.displaysAsynchronously = false + self.placeholderNode.attributedText = NSAttributedString(string: placeholder, font: Font.regular(13.0), textColor: self.theme.actionSheet.inputPlaceholderColor) + + super.init() + + self.textInputNode.delegate = self + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.textInputNode) + self.addSubnode(self.placeholderNode) + } + + func updateTheme(_ theme: PresentationTheme) { + self.theme = theme + + self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 16.0, color: self.theme.actionSheet.inputHollowBackgroundColor, strokeColor: self.theme.actionSheet.inputBorderColor, strokeWidth: 1.0) + self.textInputNode.keyboardAppearance = self.theme.rootController.keyboardColor.keyboardAppearance + self.placeholderNode.attributedText = NSAttributedString(string: self.placeholderNode.attributedText?.string ?? "", font: Font.regular(13.0), textColor: self.theme.actionSheet.inputPlaceholderColor) + self.textInputNode.tintColor = self.theme.actionSheet.controlAccentColor + } + + func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + let backgroundInsets = self.backgroundInsets + let inputInsets = self.inputInsets + + let textFieldHeight = self.calculateTextFieldMetrics(width: width) + let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom + + let backgroundFrame = CGRect(origin: CGPoint(x: backgroundInsets.left, y: backgroundInsets.top), size: CGSize(width: width - backgroundInsets.left - backgroundInsets.right, height: panelHeight - backgroundInsets.top - backgroundInsets.bottom)) + transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) + + let placeholderSize = self.placeholderNode.measure(backgroundFrame.size) + transition.updateFrame(node: self.placeholderNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left + 5.0, y: backgroundFrame.minY + floor((backgroundFrame.size.height - placeholderSize.height) / 2.0)), size: placeholderSize)) + + transition.updateFrame(node: self.textInputNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.size.width - inputInsets.left - inputInsets.right, height: backgroundFrame.size.height))) + + let characterLimitString: String + let characterLimitColor: UIColor + if self.text.count <= self.characterLimit { + let remaining = self.characterLimit - self.text.count + if remaining < 5 { + characterLimitString = "\(remaining)" + } else { + characterLimitString = " " + } + characterLimitColor = self.theme.list.itemPlaceholderTextColor + } else { + characterLimitString = "\(self.characterLimit - self.text.count)" + characterLimitColor = self.theme.list.itemDestructiveColor + } + + let characterLimitSize = self.characterLimitView.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: characterLimitString, font: Font.regular(13.0), textColor: characterLimitColor)) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let characterLimitComponentView = self.characterLimitView.view { + if characterLimitComponentView.superview == nil { + self.view.addSubview(characterLimitComponentView) + } + characterLimitComponentView.frame = CGRect(origin: CGPoint(x: width - 23.0 - characterLimitSize.width, y: 18.0), size: characterLimitSize) + } + + return panelHeight + } + + func activateInput() { + self.textInputNode.becomeFirstResponder() + } + + func deactivateInput() { + self.textInputNode.resignFirstResponder() + } + + @objc func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { + self.updateTextNodeText(animated: true) + self.textChanged?(editableTextNode.textView.text) + self.placeholderNode.isHidden = !(editableTextNode.textView.text ?? "").isEmpty + } + + func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if text == "\n" { + self.complete?() + return false + } + return true + } + + private func calculateTextFieldMetrics(width: CGFloat) -> CGFloat { + let backgroundInsets = self.backgroundInsets + let inputInsets = self.inputInsets + + let unboundTextFieldHeight = max(34.0, ceil(self.textInputNode.measure(CGSize(width: width - backgroundInsets.left - backgroundInsets.right - inputInsets.left - inputInsets.right, height: CGFloat.greatestFiniteMagnitude)).height)) + + return min(61.0, max(34.0, unboundTextFieldHeight)) + } + + private func updateTextNodeText(animated: Bool) { + let backgroundInsets = self.backgroundInsets + + let textFieldHeight = self.calculateTextFieldMetrics(width: self.bounds.size.width) + + let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom + if !self.bounds.size.height.isEqual(to: panelHeight) { + self.updateHeight?() + } + } + + @objc func clearPressed() { + self.textInputNode.attributedText = nil + self.deactivateInput() + } +} + +private final class QuickReplyNameAlertContentNode: AlertContentNode { + private let context: AccountContext + private var theme: AlertControllerTheme + private let strings: PresentationStrings + private let text: String + private let subtext: String + private let titleFont: PromptControllerTitleFont + + private let textView = ComponentView() + private let subtextView = ComponentView() + + let inputFieldNode: PromptInputFieldNode + + private let actionNodesSeparator: ASDisplayNode + private let actionNodes: [TextAlertContentActionNode] + private let actionVerticalSeparators: [ASDisplayNode] + + private let disposable = MetaDisposable() + + private var validLayout: CGSize? + + private let hapticFeedback = HapticFeedback() + + var complete: (() -> Void)? { + didSet { + self.inputFieldNode.complete = self.complete + } + } + + override var dismissOnOutsideTap: Bool { + return self.isUserInteractionEnabled + } + + init(context: AccountContext, theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, actions: [TextAlertAction], text: String, subtext: String, titleFont: PromptControllerTitleFont, value: String?, characterLimit: Int) { + self.context = context + self.theme = theme + self.strings = strings + self.text = text + self.subtext = subtext + self.titleFont = titleFont + + //TODO:localize + self.inputFieldNode = PromptInputFieldNode(theme: ptheme, placeholder: "Shortcut", characterLimit: characterLimit) + self.inputFieldNode.text = value ?? "" + + self.actionNodesSeparator = ASDisplayNode() + self.actionNodesSeparator.isLayerBacked = true + + self.actionNodes = actions.map { action -> TextAlertContentActionNode in + return TextAlertContentActionNode(theme: theme, action: action) + } + + var actionVerticalSeparators: [ASDisplayNode] = [] + if actions.count > 1 { + for _ in 0 ..< actions.count - 1 { + let separatorNode = ASDisplayNode() + separatorNode.isLayerBacked = true + actionVerticalSeparators.append(separatorNode) + } + } + self.actionVerticalSeparators = actionVerticalSeparators + + super.init() + + self.addSubnode(self.inputFieldNode) + + self.addSubnode(self.actionNodesSeparator) + + for actionNode in self.actionNodes { + self.addSubnode(actionNode) + } + self.actionNodes.last?.actionEnabled = true + + for separatorNode in self.actionVerticalSeparators { + self.addSubnode(separatorNode) + } + + self.inputFieldNode.updateHeight = { [weak self] in + if let strongSelf = self { + if let _ = strongSelf.validLayout { + strongSelf.requestLayout?(.immediate) + } + } + } + + self.inputFieldNode.textChanged = { [weak self] text in + if let strongSelf = self, let lastNode = strongSelf.actionNodes.last { + lastNode.actionEnabled = text.count <= characterLimit + strongSelf.requestLayout?(.immediate) + } + } + + self.updateTheme(theme) + } + + deinit { + self.disposable.dispose() + } + + var value: String { + return self.inputFieldNode.text + } + + override func updateTheme(_ theme: AlertControllerTheme) { + self.theme = theme + + self.actionNodesSeparator.backgroundColor = theme.separatorColor + for actionNode in self.actionNodes { + actionNode.updateTheme(theme) + } + for separatorNode in self.actionVerticalSeparators { + separatorNode.backgroundColor = theme.separatorColor + } + + if let size = self.validLayout { + _ = self.updateLayout(size: size, transition: .immediate) + } + } + + override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { + var size = size + size.width = min(size.width, 270.0) + let measureSize = CGSize(width: size.width - 16.0 * 2.0, height: CGFloat.greatestFiniteMagnitude) + + let hadValidLayout = self.validLayout != nil + + self.validLayout = size + + var origin: CGPoint = CGPoint(x: 0.0, y: 16.0) + let spacing: CGFloat = 5.0 + let subtextSpacing: CGFloat = -1.0 + + let textSize = self.textView.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: self.text, font: Font.semibold(17.0), textColor: self.theme.primaryColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: measureSize.width, height: 1000.0) + ) + let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: origin.y), size: textSize) + if let textComponentView = self.textView.view { + if textComponentView.superview == nil { + self.view.addSubview(textComponentView) + } + textComponentView.frame = textFrame + } + origin.y += textSize.height + 6.0 + subtextSpacing + + let subtextSize = self.subtextView.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(NSAttributedString(string: self.subtext, font: Font.regular(13.0), textColor: self.theme.primaryColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: measureSize.width, height: 1000.0) + ) + let subtextFrame = CGRect(origin: CGPoint(x: floor((size.width - subtextSize.width) * 0.5), y: origin.y), size: subtextSize) + if let subtextComponentView = self.subtextView.view { + if subtextComponentView.superview == nil { + self.view.addSubview(subtextComponentView) + } + subtextComponentView.frame = subtextFrame + } + origin.y += subtextSize.height + 6.0 + spacing + + let actionButtonHeight: CGFloat = 44.0 + var minActionsWidth: CGFloat = 0.0 + let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) + let actionTitleInsets: CGFloat = 8.0 + + var effectiveActionLayout = TextAlertContentActionLayout.horizontal + for actionNode in self.actionNodes { + let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight)) + if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 { + effectiveActionLayout = .vertical + } + switch effectiveActionLayout { + case .horizontal: + minActionsWidth += actionTitleSize.width + actionTitleInsets + case .vertical: + minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets) + } + } + + let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 9.0, right: 18.0) + + var contentWidth = max(textSize.width, minActionsWidth) + contentWidth = max(subtextSize.width, minActionsWidth) + contentWidth = max(contentWidth, 234.0) + + var actionsHeight: CGFloat = 0.0 + switch effectiveActionLayout { + case .horizontal: + actionsHeight = actionButtonHeight + case .vertical: + actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count) + } + + let resultWidth = contentWidth + insets.left + insets.right + + let inputFieldWidth = resultWidth + let inputFieldHeight = self.inputFieldNode.updateLayout(width: inputFieldWidth, transition: transition) + let inputHeight = inputFieldHeight + let inputFieldFrame = CGRect(x: 0.0, y: origin.y, width: resultWidth, height: inputFieldHeight) + transition.updateFrame(node: self.inputFieldNode, frame: inputFieldFrame) + transition.updateAlpha(node: self.inputFieldNode, alpha: inputHeight > 0.0 ? 1.0 : 0.0) + + let resultSize = CGSize(width: resultWidth, height: textSize.height + subtextSpacing + subtextSize.height + spacing + inputHeight + actionsHeight + insets.top + insets.bottom) + + transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) + + var actionOffset: CGFloat = 0.0 + let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count)) + var separatorIndex = -1 + var nodeIndex = 0 + for actionNode in self.actionNodes { + if separatorIndex >= 0 { + let separatorNode = self.actionVerticalSeparators[separatorIndex] + switch effectiveActionLayout { + case .horizontal: + transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel))) + case .vertical: + transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) + } + } + separatorIndex += 1 + + let currentActionWidth: CGFloat + switch effectiveActionLayout { + case .horizontal: + if nodeIndex == self.actionNodes.count - 1 { + currentActionWidth = resultSize.width - actionOffset + } else { + currentActionWidth = actionWidth + } + case .vertical: + currentActionWidth = resultSize.width + } + + let actionNodeFrame: CGRect + switch effectiveActionLayout { + case .horizontal: + actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) + actionOffset += currentActionWidth + case .vertical: + actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) + actionOffset += actionButtonHeight + } + + transition.updateFrame(node: actionNode, frame: actionNodeFrame) + + nodeIndex += 1 + } + + if !hadValidLayout { + self.inputFieldNode.activateInput() + } + + return resultSize + } + + func animateError() { + self.inputFieldNode.layer.addShakeAnimation() + self.hapticFeedback.error() + } +} + +public enum PromptControllerTitleFont { + case regular + case bold +} + +public func quickReplyNameAlertController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, text: String, subtext: String, titleFont: PromptControllerTitleFont = .regular, value: String?, characterLimit: Int = 1000, apply: @escaping (String?) -> Void) -> AlertController { + let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } + + var dismissImpl: ((Bool) -> Void)? + var applyImpl: (() -> Void)? + + let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { + dismissImpl?(true) + apply(nil) + }), TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Done, action: { + applyImpl?() + })] + + let contentNode = QuickReplyNameAlertContentNode(context: context, theme: AlertControllerTheme(presentationData: presentationData), ptheme: presentationData.theme, strings: presentationData.strings, actions: actions, text: text, subtext: subtext, titleFont: titleFont, value: value, characterLimit: characterLimit) + contentNode.complete = { + applyImpl?() + } + applyImpl = { [weak contentNode] in + guard let contentNode = contentNode else { + return + } + apply(contentNode.value) + } + + let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode) + let presentationDataDisposable = (updatedPresentationData?.signal ?? context.sharedContext.presentationData).start(next: { [weak controller, weak contentNode] presentationData in + controller?.theme = AlertControllerTheme(presentationData: presentationData) + contentNode?.inputFieldNode.updateTheme(presentationData.theme) + }) + controller.dismissed = { _ in + presentationDataDisposable.dispose() + } + dismissImpl = { [weak controller] animated in + contentNode.inputFieldNode.deactivateInput() + if animated { + controller?.dismissAnimated() + } else { + controller?.dismiss() + } + } + return controller +} diff --git a/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/BUILD b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/BUILD new file mode 100644 index 0000000000..383288eeab --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/BUILD @@ -0,0 +1,30 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "TimezoneSelectionScreen", + module_name = "TimezoneSelectionScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/AsyncDisplayKit", + "//submodules/Postbox", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramCore", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/SearchUI", + "//submodules/MergeLists", + "//submodules/ItemListUI", + "//submodules/PresentationDataUtils", + "//submodules/SearchBarNode", + "//submodules/TelegramUIPreferences", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreen.swift b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreen.swift new file mode 100644 index 0000000000..da4b3de534 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreen.swift @@ -0,0 +1,156 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import AccountContext +import SearchUI + +public class TimezoneSelectionScreen: ViewController { + private let context: AccountContext + private let completed: (String) -> Void + + private var controllerNode: TimezoneSelectionScreenNode { + return self.displayNode as! TimezoneSelectionScreenNode + } + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private var searchContentNode: NavigationBarSearchContentNode? + + private var previousContentOffset: ListViewVisibleContentOffset? + + public init(context: AccountContext, completed: @escaping (String) -> Void) { + self.context = context + self.completed = completed + + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + + super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData)) + + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style + + //TODO:localize + self.title = "Time Zone" + + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + if let searchContentNode = strongSelf.searchContentNode { + searchContentNode.updateExpansionProgress(1.0, animated: true) + } + strongSelf.controllerNode.scrollToTop() + } + } + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings() + } + } + }) + + self.searchContentNode = NavigationBarSearchContentNode(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search, inline: true, activate: { [weak self] in + self?.activateSearch() + }) + self.navigationBar?.setContentNode(self.searchContentNode, animated: false) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + private func updateThemeAndStrings() { + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style + self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData)) + self.searchContentNode?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search) + self.title = self.presentationData.strings.Settings_AppLanguage + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + self.controllerNode.updatePresentationData(self.presentationData) + } + + override public func loadDisplayNode() { + self.displayNode = TimezoneSelectionScreenNode(context: self.context, presentationData: self.presentationData, navigationBar: self.navigationBar!, requestActivateSearch: { [weak self] in + self?.activateSearch() + }, requestDeactivateSearch: { [weak self] in + self?.deactivateSearch() + }, action: { [weak self] id in + guard let self else { + return + } + self.completed(id) + }, present: { [weak self] c, a in + self?.present(c, in: .window(.root), with: a) + }, push: { [weak self] c in + self?.push(c) + }) + + self.controllerNode.listNode.visibleContentOffsetChanged = { [weak self] offset in + if let strongSelf = self { + if let searchContentNode = strongSelf.searchContentNode { + searchContentNode.updateListVisibleContentOffset(offset) + } + + strongSelf.previousContentOffset = offset + } + } + + self.controllerNode.listNode.didEndScrolling = { [weak self] _ in + if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode { + let _ = fixNavigationSearchableListNodeScrolling(strongSelf.controllerNode.listNode, searchNode: searchContentNode) + } + } + + self._ready.set(self.controllerNode._ready.get()) + + self.displayNodeDidLoad() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, transition: transition) + } + + private func activateSearch() { + if self.displayNavigationBar { + if let scrollToTop = self.scrollToTop { + scrollToTop() + } + if let searchContentNode = self.searchContentNode { + self.controllerNode.activateSearch(placeholderNode: searchContentNode.placeholderNode) + } + self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring)) + } + } + + private func deactivateSearch() { + if !self.displayNavigationBar { + self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring)) + if let searchContentNode = self.searchContentNode { + self.controllerNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode) + } + } + } +} diff --git a/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift new file mode 100644 index 0000000000..8656ab32ea --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift @@ -0,0 +1,477 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import MergeLists +import ItemListUI +import PresentationDataUtils +import AccountContext +import SearchBarNode +import SearchUI +import TelegramUIPreferences + +private struct TimezoneListEntry: Comparable, Identifiable { + var id: String + var offset: Int + var title: String + + var stableId: String { + return self.id + } + + static func <(lhs: TimezoneListEntry, rhs: TimezoneListEntry) -> Bool { + if lhs.offset != rhs.offset { + return lhs.offset < rhs.offset + } + if lhs.title != rhs.title { + return lhs.title < rhs.title + } + return lhs.id < rhs.id + } + + func item(presentationData: PresentationData, searchMode: Bool, action: @escaping (String) -> Void) -> ListViewItem { + return ItemListActionItem(presentationData: ItemListPresentationData(presentationData), title: self.title, kind: .neutral, alignment: .natural, sectionId: 0, style: .plain, action: { + action(self.id) + }) + } +} + +private struct TimezoneListSearchContainerTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let isSearching: Bool +} + +private func preparedLanguageListSearchContainerTransition(presentationData: PresentationData, from fromEntries: [TimezoneListEntry], to toEntries: [TimezoneListEntry], action: @escaping (String) -> Void, isSearching: Bool, forceUpdate: Bool) -> TimezoneListSearchContainerTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries, allUpdated: forceUpdate) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: true, action: action), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: true, action: action), directionHint: nil) } + + return TimezoneListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching) +} + +private final class TimezoneListSearchContainerNode: SearchDisplayControllerContentNode { + private let timezoneData: TimezoneData + + private let dimNode: ASDisplayNode + private let listNode: ListView + + private var enqueuedTransitions: [TimezoneListSearchContainerTransition] = [] + private var hasValidLayout = false + + private let searchQuery = Promise() + private let searchDisposable = MetaDisposable() + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private let presentationDataPromise: Promise + + public override var hasDim: Bool { + return true + } + + init(context: AccountContext, timezoneData: TimezoneData, action: @escaping (String) -> Void) { + self.timezoneData = timezoneData + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.presentationData = presentationData + + self.presentationDataPromise = Promise(self.presentationData) + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5) + + self.listNode = ListView() + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + super.init() + + self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor + self.listNode.isHidden = true + + self.addSubnode(self.dimNode) + self.addSubnode(self.listNode) + + let foundItems = self.searchQuery.get() + |> mapToSignal { query -> Signal<[TimezoneData.Item]?, NoError> in + if let query, !query.isEmpty { + let query = query.lowercased() + + return .single(timezoneData.items.filter { item in + if item.id.lowercased().hasPrefix(query) { + return true + } + if item.title.lowercased().split(separator: " ").contains(where: { $0.hasPrefix(query) }) { + return true + } + + return false + }) + } else { + return .single(nil) + } + } + + let previousEntriesHolder = Atomic<([TimezoneListEntry], PresentationTheme, PresentationStrings)?>(value: nil) + self.searchDisposable.set(combineLatest(queue: .mainQueue(), foundItems, self.presentationDataPromise.get()).start(next: { [weak self] items, presentationData in + guard let strongSelf = self else { + return + } + var entries: [TimezoneListEntry] = [] + if let items { + for item in items { + entries.append(TimezoneListEntry( + id: item.id, + offset: item.offset, + title: item.title + )) + } + } + entries.sort() + let previousEntriesAndPresentationData = previousEntriesHolder.swap((entries, presentationData.theme, presentationData.strings)) + let transition = preparedLanguageListSearchContainerTransition(presentationData: presentationData, from: previousEntriesAndPresentationData?.0 ?? [], to: entries, action: action, isSearching: items != nil, forceUpdate: previousEntriesAndPresentationData?.1 !== presentationData.theme || previousEntriesAndPresentationData?.2 !== presentationData.strings) + strongSelf.enqueueTransition(transition) + })) + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + let previousTheme = strongSelf.presentationData.theme + let previousStrings = strongSelf.presentationData.strings + + strongSelf.presentationData = presentationData + + if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { + strongSelf.updateThemeAndStrings(theme: presentationData.theme, strings: presentationData.strings) + strongSelf.presentationDataPromise.set(.single(presentationData)) + } + } + }) + + self.listNode.beganInteractiveDragging = { [weak self] _ in + self?.dismissInput?() + } + } + + deinit { + self.searchDisposable.dispose() + self.presentationDataDisposable?.dispose() + } + + override func didLoad() { + super.didLoad() + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + } + + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.listNode.backgroundColor = theme.chatList.backgroundColor + } + + override func searchTextUpdated(text: String) { + if text.isEmpty { + self.searchQuery.set(.single(nil)) + } else { + self.searchQuery.set(.single(text)) + } + } + + private func enqueueTransition(_ transition: TimezoneListSearchContainerTransition) { + self.enqueuedTransitions.append(transition) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransitions() + } + } + } + + private func dequeueTransitions() { + if let transition = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + options.insert(.PreferSynchronousDrawing) + + let isSearching = transition.isSearching + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + self?.listNode.isHidden = !isSearching + self?.dimNode.isHidden = isSearching + }) + } + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + + let topInset = navigationBarHeight + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset))) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + + self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: navigationBarHeight, left: layout.safeInsets.left, bottom: layout.insets(options: [.input]).bottom, right: layout.safeInsets.right), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !self.hasValidLayout { + self.hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransitions() + } + } + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancel?() + } + } +} + +private struct TimezoneListNodeTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let firstTime: Bool + let isLoading: Bool + let animated: Bool + let crossfade: Bool +} + +private func preparedTimezoneListNodeTransition(presentationData: PresentationData, from fromEntries: [TimezoneListEntry], to toEntries: [TimezoneListEntry], action: @escaping (String) -> Void, firstTime: Bool, isLoading: Bool, forceUpdate: Bool, animated: Bool, crossfade: Bool) -> TimezoneListNodeTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries, allUpdated: forceUpdate) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: false, action: action), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, searchMode: false, action: action), directionHint: nil) } + + return TimezoneListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, firstTime: firstTime, isLoading: isLoading, animated: animated, crossfade: crossfade) +} + +private final class TimezoneData { + struct Item { + var id: String + var offset: Int + var title: String + + init(id: String, offset: Int, title: String) { + self.id = id + self.offset = offset + self.title = title + } + } + + let items: [Item] + + init() { + let locale = Locale.current + var items: [Item] = [] + for (key, value) in TimeZone.abbreviationDictionary { + guard let timezone = TimeZone(abbreviation: key) else { + continue + } + if items.contains(where: { $0.id == timezone.identifier }) { + continue + } + items.append(Item( + id: timezone.identifier, + offset: timezone.secondsFromGMT(), + title: timezone.localizedName(for: .standard, locale: locale) ?? value + )) + } + self.items = items + } +} + +final class TimezoneSelectionScreenNode: ViewControllerTracingNode { + private let context: AccountContext + private let action: (String) -> Void + private var presentationData: PresentationData + private weak var navigationBar: NavigationBar? + private let requestActivateSearch: () -> Void + private let requestDeactivateSearch: () -> Void + private let present: (ViewController, Any?) -> Void + private let push: (ViewController) -> Void + private let timezoneData: TimezoneData + + private var didSetReady = false + let _ready = ValuePromise() + + private var containerLayout: (ContainerViewLayout, CGFloat)? + let listNode: ListView + private var queuedTransitions: [TimezoneListNodeTransition] = [] + private var searchDisplayController: SearchDisplayController? + + private let presentationDataValue = Promise() + + private var listDisposable: Disposable? + + init(context: AccountContext, presentationData: PresentationData, navigationBar: NavigationBar, requestActivateSearch: @escaping () -> Void, requestDeactivateSearch: @escaping () -> Void, action: @escaping (String) -> Void, present: @escaping (ViewController, Any?) -> Void, push: @escaping (ViewController) -> Void) { + self.context = context + self.action = action + self.presentationData = presentationData + self.presentationDataValue.set(.single(presentationData)) + self.navigationBar = navigationBar + self.requestActivateSearch = requestActivateSearch + self.requestDeactivateSearch = requestDeactivateSearch + self.present = present + self.push = push + + self.listNode = ListView() + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + let timezoneData = TimezoneData() + self.timezoneData = timezoneData + + super.init() + + self.backgroundColor = presentationData.theme.list.plainBackgroundColor + self.addSubnode(self.listNode) + + let previousEntriesHolder = Atomic<([TimezoneListEntry], PresentationTheme, PresentationStrings)?>(value: nil) + self.listDisposable = (self.presentationDataValue.get() + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + guard let strongSelf = self else { + return + } + + var entries: [TimezoneListEntry] = [] + for item in timezoneData.items { + entries.append(TimezoneListEntry( + id: item.id, + offset: item.offset, + title: item.title + )) + } + entries.sort() + + let previousEntriesAndPresentationData = previousEntriesHolder.swap((entries, presentationData.theme, presentationData.strings)) + let transition = preparedTimezoneListNodeTransition(presentationData: presentationData, from: previousEntriesAndPresentationData?.0 ?? [], to: entries, action: action, firstTime: previousEntriesAndPresentationData == nil, isLoading: entries.isEmpty, forceUpdate: previousEntriesAndPresentationData?.1 !== presentationData.theme || previousEntriesAndPresentationData?.2 !== presentationData.strings, animated: false, crossfade: false) + strongSelf.enqueueTransition(transition) + }) + } + + deinit { + self.listDisposable?.dispose() + } + + func updatePresentationData(_ presentationData: PresentationData) { + let stringsUpdated = self.presentationData.strings !== presentationData.strings + self.presentationData = presentationData + + if stringsUpdated { + if let snapshotView = self.view.snapshotView(afterScreenUpdates: false) { + self.view.addSubview(snapshotView) + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + } + + self.presentationDataValue.set(.single(presentationData)) + self.backgroundColor = presentationData.theme.list.plainBackgroundColor + self.searchDisplayController?.updatePresentationData(presentationData) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + let hadValidLayout = self.containerLayout != nil + self.containerLayout = (layout, navigationBarHeight) + + var listInsets = layout.insets(options: [.input]) + listInsets.top += navigationBarHeight + + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + + self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: listInsets, duration: duration, curve: curve) + + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hadValidLayout { + self.dequeueTransitions() + } + } + + private func enqueueTransition(_ transition: TimezoneListNodeTransition) { + self.queuedTransitions.append(transition) + + if self.containerLayout != nil { + self.dequeueTransitions() + } + } + + private func dequeueTransitions() { + guard let _ = self.containerLayout else { + return + } + while !self.queuedTransitions.isEmpty { + let transition = self.queuedTransitions.removeFirst() + + var options = ListViewDeleteAndInsertOptions() + if transition.firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if transition.crossfade { + options.insert(.AnimateCrossfade) + } else if transition.animated { + options.insert(.AnimateInsertion) + } + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(true) + } + } + }) + } + } + + func activateSearch(placeholderNode: SearchBarPlaceholderNode) { + guard let (containerLayout, navigationBarHeight) = self.containerLayout, self.searchDisplayController == nil else { + return + } + + self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, contentNode: TimezoneListSearchContainerNode(context: self.context, timezoneData: self.timezoneData, action: self.action), inline: true, cancel: { [weak self] in + self?.requestDeactivateSearch() + }) + + self.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, transition: .immediate) + self.searchDisplayController?.activate(insertSubnode: { [weak self, weak placeholderNode] subnode, isSearchBar in + if let strongSelf = self, let strongPlaceholderNode = placeholderNode { + if isSearchBar { + strongPlaceholderNode.supernode?.insertSubnode(subnode, aboveSubnode: strongPlaceholderNode) + } else if let navigationBar = strongSelf.navigationBar { + strongSelf.insertSubnode(subnode, belowSubnode: navigationBar) + } + } + }, placeholder: placeholderNode) + } + + func deactivateSearch(placeholderNode: SearchBarPlaceholderNode) { + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.deactivate(placeholder: placeholderNode) + self.searchDisplayController = nil + } + } + + func scrollToTop() { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } +} diff --git a/submodules/TelegramUI/Components/SliderComponent/BUILD b/submodules/TelegramUI/Components/SliderComponent/BUILD index 2a25e8d670..6ed98b159f 100644 --- a/submodules/TelegramUI/Components/SliderComponent/BUILD +++ b/submodules/TelegramUI/Components/SliderComponent/BUILD @@ -13,9 +13,8 @@ swift_library( "//submodules/AsyncDisplayKit", "//submodules/Display", "//submodules/TelegramPresentationData", - "//submodules/LegacyUI", + "//submodules/LegacyComponents", "//submodules/ComponentFlow", - "//submodules/Components/MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift index 2d314abc8f..581ac081bc 100644 --- a/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift +++ b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift @@ -3,96 +3,69 @@ import UIKit import Display import AsyncDisplayKit import TelegramPresentationData -import LegacyUI +import LegacyComponents import ComponentFlow -import MultilineTextComponent -final class SliderComponent: Component { - typealias EnvironmentType = Empty +public final class SliderComponent: Component { + public let valueCount: Int + public let value: Int + public let trackBackgroundColor: UIColor + public let trackForegroundColor: UIColor + public let valueUpdated: (Int) -> Void + public let isTrackingUpdated: ((Bool) -> Void)? - let title: String - let value: Float - let minValue: Float - let maxValue: Float - let startValue: Float - let isEnabled: Bool - let trackColor: UIColor? - let displayValue: Bool - let valueUpdated: (Float) -> Void - let isTrackingUpdated: ((Bool) -> Void)? - - init( - title: String, - value: Float, - minValue: Float, - maxValue: Float, - startValue: Float, - isEnabled: Bool, - trackColor: UIColor?, - displayValue: Bool, - valueUpdated: @escaping (Float) -> Void, + public init( + valueCount: Int, + value: Int, + trackBackgroundColor: UIColor, + trackForegroundColor: UIColor, + valueUpdated: @escaping (Int) -> Void, isTrackingUpdated: ((Bool) -> Void)? = nil ) { - self.title = title + self.valueCount = valueCount self.value = value - self.minValue = minValue - self.maxValue = maxValue - self.startValue = startValue - self.isEnabled = isEnabled - self.trackColor = trackColor - self.displayValue = displayValue + self.trackBackgroundColor = trackBackgroundColor + self.trackForegroundColor = trackForegroundColor self.valueUpdated = valueUpdated self.isTrackingUpdated = isTrackingUpdated } - static func ==(lhs: SliderComponent, rhs: SliderComponent) -> Bool { - if lhs.title != rhs.title { + public static func ==(lhs: SliderComponent, rhs: SliderComponent) -> Bool { + if lhs.valueCount != rhs.valueCount { return false } if lhs.value != rhs.value { return false } - if lhs.minValue != rhs.minValue { + if lhs.trackBackgroundColor != rhs.trackBackgroundColor { return false } - if lhs.maxValue != rhs.maxValue { - return false - } - if lhs.startValue != rhs.startValue { - return false - } - if lhs.isEnabled != rhs.isEnabled { - return false - } - if lhs.trackColor != rhs.trackColor { - return false - } - if lhs.displayValue != rhs.displayValue { + if lhs.trackForegroundColor != rhs.trackForegroundColor { return false } return true } - final class View: UIView, UITextFieldDelegate { - private let title = ComponentView() - private let value = ComponentView() + public final class View: UIView { private var sliderView: TGPhotoEditorSliderView? private var component: SliderComponent? private weak var state: EmptyComponentState? - override init(frame: CGRect) { + override public init(frame: CGRect) { super.init(frame: frame) } - required init?(coder: NSCoder) { + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func update(component: SliderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + func update(component: SliderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state + let size = CGSize(width: availableSize.width, height: 44.0) + var internalIsTrackingUpdated: ((Bool) -> Void)? if let isTrackingUpdated = component.isTrackingUpdated { internalIsTrackingUpdated = { [weak self] isTracking in @@ -100,23 +73,11 @@ final class SliderComponent: Component { if isTracking { self.sliderView?.bordered = true } else { - Queue.mainQueue().after(0.1) { - self.sliderView?.bordered = false - } + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { [weak self] in + self?.sliderView?.bordered = false + }) } isTrackingUpdated(isTracking) - let transition: Transition - if isTracking { - transition = .immediate - } else { - transition = .easeInOut(duration: 0.25) - } - if let titleView = self.title.view { - transition.setAlpha(view: titleView, alpha: isTracking ? 0.0 : 1.0) - } - if let valueView = self.value.view { - transition.setAlpha(view: valueView, alpha: isTracking ? 0.0 : 1.0) - } } } } @@ -124,24 +85,42 @@ final class SliderComponent: Component { let sliderView: TGPhotoEditorSliderView if let current = self.sliderView { sliderView = current - sliderView.value = CGFloat(component.value) } else { sliderView = TGPhotoEditorSliderView() - sliderView.backgroundColor = .clear - sliderView.startColor = UIColor(rgb: 0xffffff) sliderView.enablePanHandling = true - sliderView.trackCornerRadius = 1.0 - sliderView.lineSize = 2.0 - sliderView.minimumValue = CGFloat(component.minValue) - sliderView.maximumValue = CGFloat(component.maxValue) - sliderView.startValue = CGFloat(component.startValue) - sliderView.value = CGFloat(component.value) + sliderView.trackCornerRadius = 2.0 + sliderView.lineSize = 4.0 + sliderView.dotSize = 5.0 + sliderView.minimumValue = 0.0 + sliderView.startValue = 0.0 + sliderView.disablesInteractiveTransitionGestureRecognizer = true + sliderView.maximumValue = CGFloat(component.valueCount) + sliderView.positionsCount = component.valueCount + 1 + sliderView.useLinesForPositions = true + + sliderView.backgroundColor = nil + sliderView.isOpaque = false + sliderView.backColor = component.trackBackgroundColor + sliderView.startColor = component.trackBackgroundColor + sliderView.trackColor = component.trackForegroundColor + sliderView.knobImage = generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setShadow(offset: CGSize(width: 0.0, height: -3.0), blur: 12.0, color: UIColor(white: 0.0, alpha: 0.25).cgColor) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: 28.0, height: 28.0))) + }) + + sliderView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size) + sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) + + sliderView.disablesInteractiveTransitionGestureRecognizer = true sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged) sliderView.layer.allowsGroupOpacity = true self.sliderView = sliderView self.addSubview(sliderView) } + sliderView.value = CGFloat(component.value) sliderView.interactionBegan = { internalIsTrackingUpdated?(true) } @@ -149,70 +128,17 @@ final class SliderComponent: Component { internalIsTrackingUpdated?(false) } - if component.isEnabled { - sliderView.alpha = 1.3 - sliderView.trackColor = component.trackColor ?? UIColor(rgb: 0xffffff) - sliderView.isUserInteractionEnabled = true - } else { - sliderView.trackColor = UIColor(rgb: 0xffffff) - sliderView.alpha = 0.3 - sliderView.isUserInteractionEnabled = false - } + transition.setFrame(view: sliderView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: 44.0))) + sliderView.hitTestEdgeInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) - transition.setFrame(view: sliderView, frame: CGRect(origin: CGPoint(x: 22.0, y: 7.0), size: CGSize(width: availableSize.width - 22.0 * 2.0, height: 44.0))) - sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) - - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent( - Text(text: component.title, font: Font.regular(14.0), color: UIColor(rgb: 0x808080)) - ), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - if let titleView = self.title.view { - if titleView.superview == nil { - self.addSubview(titleView) - } - transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: 21.0, y: 0.0), size: titleSize)) - } - - let valueText: String - if component.displayValue { - if component.value > 0.005 { - valueText = String(format: "+%.2f", component.value) - } else if component.value < -0.005 { - valueText = String(format: "%.2f", component.value) - } else { - valueText = "" - } - } else { - valueText = "" - } - - let valueSize = self.value.update( - transition: .immediate, - component: AnyComponent( - Text(text: valueText, font: Font.with(size: 14.0, traits: .monospacedNumbers), color: UIColor(rgb: 0xf8d74a)) - ), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - if let valueView = self.value.view { - if valueView.superview == nil { - self.addSubview(valueView) - } - transition.setFrame(view: valueView, frame: CGRect(origin: CGPoint(x: availableSize.width - 21.0 - valueSize.width, y: 0.0), size: valueSize)) - } - - return CGSize(width: availableSize.width, height: 52.0) + return size } @objc private func sliderValueChanged() { guard let component = self.component, let sliderView = self.sliderView else { return } - component.valueUpdated(Float(sliderView.value)) + component.valueUpdated(Int(sliderView.value)) } } @@ -220,240 +146,7 @@ final class SliderComponent: Component { return View(frame: CGRect()) } - public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} - -struct AdjustmentTool: Equatable { - let key: EditorToolKey - let title: String - let value: Float - let minValue: Float - let maxValue: Float - let startValue: Float -} - -final class AdjustmentsComponent: Component { - typealias EnvironmentType = Empty - - let tools: [AdjustmentTool] - let valueUpdated: (EditorToolKey, Float) -> Void - let isTrackingUpdated: (Bool) -> Void - - init( - tools: [AdjustmentTool], - valueUpdated: @escaping (EditorToolKey, Float) -> Void, - isTrackingUpdated: @escaping (Bool) -> Void - ) { - self.tools = tools - self.valueUpdated = valueUpdated - self.isTrackingUpdated = isTrackingUpdated - } - - static func ==(lhs: AdjustmentsComponent, rhs: AdjustmentsComponent) -> Bool { - if lhs.tools != rhs.tools { - return false - } - return true - } - - final class View: UIView { - private let scrollView = UIScrollView() - private var toolViews: [ComponentView] = [] - - private var component: AdjustmentsComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - self.scrollView.showsVerticalScrollIndicator = false - - super.init(frame: frame) - - self.addSubview(self.scrollView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: AdjustmentsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - self.component = component - self.state = state - - let valueUpdated = component.valueUpdated - let isTrackingUpdated: (EditorToolKey, Bool) -> Void = { [weak self] trackingTool, isTracking in - component.isTrackingUpdated(isTracking) - - if let self { - for i in 0 ..< component.tools.count { - let tool = component.tools[i] - if tool.key != trackingTool && i < self.toolViews.count { - if let view = self.toolViews[i].view { - let transition: Transition - if isTracking { - transition = .immediate - } else { - transition = .easeInOut(duration: 0.25) - } - transition.setAlpha(view: view, alpha: isTracking ? 0.0 : 1.0) - } - } - } - } - } - - var sizes: [CGSize] = [] - for i in 0 ..< component.tools.count { - let tool = component.tools[i] - let componentView: ComponentView - if i >= self.toolViews.count { - componentView = ComponentView() - self.toolViews.append(componentView) - } else { - componentView = self.toolViews[i] - } - - var valueIsNegative = false - var value = tool.value - if case .enhance = tool.key { - if value < 0.0 { - valueIsNegative = true - } - value = abs(value) - } - - let size = componentView.update( - transition: transition, - component: AnyComponent( - SliderComponent( - title: tool.title, - value: value, - minValue: tool.minValue, - maxValue: tool.maxValue, - startValue: tool.startValue, - isEnabled: true, - trackColor: nil, - displayValue: true, - valueUpdated: { value in - var updatedValue = value - if valueIsNegative { - updatedValue *= -1.0 - } - valueUpdated(tool.key, updatedValue) - }, - isTrackingUpdated: { isTracking in - isTrackingUpdated(tool.key, isTracking) - } - ) - ), - environment: {}, - containerSize: availableSize - ) - sizes.append(size) - } - - var origin: CGPoint = CGPoint(x: 0.0, y: 11.0) - for i in 0 ..< component.tools.count { - let size = sizes[i] - let componentView = self.toolViews[i] - - if let view = componentView.view { - if view.superview == nil { - self.scrollView.addSubview(view) - } - transition.setFrame(view: view, frame: CGRect(origin: origin, size: size)) - } - origin = origin.offsetBy(dx: 0.0, dy: size.height) - } - - let size = CGSize(width: availableSize.width, height: 180.0) - let contentSize = CGSize(width: availableSize.width, height: origin.y) - if contentSize != self.scrollView.contentSize { - self.scrollView.contentSize = contentSize - } - transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: size)) - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} - -final class AdjustmentsScreenComponent: Component { - typealias EnvironmentType = Empty - - let toggleUneditedPreview: (Bool) -> Void - - init( - toggleUneditedPreview: @escaping (Bool) -> Void - ) { - self.toggleUneditedPreview = toggleUneditedPreview - } - - static func ==(lhs: AdjustmentsScreenComponent, rhs: AdjustmentsScreenComponent) -> Bool { - return true - } - - final class View: UIView { - enum Field { - case blacks - case shadows - case midtones - case highlights - case whites - } - - private var component: AdjustmentsScreenComponent? - private weak var state: EmptyComponentState? - - override init(frame: CGRect) { - super.init(frame: frame) - - let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress(_:))) - longPressGestureRecognizer.minimumPressDuration = 0.05 - self.addGestureRecognizer(longPressGestureRecognizer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc func handleLongPress(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let component = self.component else { - return - } - - switch gestureRecognizer.state { - case .began: - component.toggleUneditedPreview(true) - case .ended, .cancelled: - component.toggleUneditedPreview(false) - default: - break - } - } - - func update(component: AdjustmentsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - self.component = component - self.state = state - - return availableSize - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } diff --git a/submodules/TelegramUI/Components/TimeSelectionActionSheet/BUILD b/submodules/TelegramUI/Components/TimeSelectionActionSheet/BUILD new file mode 100644 index 0000000000..b4680d882d --- /dev/null +++ b/submodules/TelegramUI/Components/TimeSelectionActionSheet/BUILD @@ -0,0 +1,25 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "TimeSelectionActionSheet", + module_name = "TimeSelectionActionSheet", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/AsyncDisplayKit", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramCore", + "//submodules/TelegramPresentationData", + "//submodules/TelegramStringFormatting", + "//submodules/AccountContext", + "//submodules/UIKitRuntimeUtils", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift b/submodules/TelegramUI/Components/TimeSelectionActionSheet/Sources/TimeSelectionActionSheet.swift similarity index 93% rename from submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift rename to submodules/TelegramUI/Components/TimeSelectionActionSheet/Sources/TimeSelectionActionSheet.swift index 048838633b..8ec3ff48af 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift +++ b/submodules/TelegramUI/Components/TimeSelectionActionSheet/Sources/TimeSelectionActionSheet.swift @@ -9,15 +9,15 @@ import TelegramStringFormatting import AccountContext import UIKitRuntimeUtils -final class TimeSelectionActionSheet: ActionSheetController { +public final class TimeSelectionActionSheet: ActionSheetController { private var presentationDisposable: Disposable? private let _ready = Promise() - override var ready: Promise { + override public var ready: Promise { return self._ready } - init(context: AccountContext, currentValue: Int32, emptyTitle: String? = nil, applyValue: @escaping (Int32?) -> Void) { + public init(context: AccountContext, currentValue: Int32, emptyTitle: String? = nil, applyValue: @escaping (Int32?) -> Void) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let strings = presentationData.strings @@ -56,7 +56,7 @@ final class TimeSelectionActionSheet: ActionSheetController { ]) } - required init(coder aDecoder: NSCoder) { + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/submodules/TelegramUI/Resources/Animations/ZzzEmoji.tgs b/submodules/TelegramUI/Resources/Animations/ZzzEmoji.tgs new file mode 100644 index 0000000000000000000000000000000000000000..b766d6e438e6d7158becd5d07b24b47225d47154 GIT binary patch literal 38806 zcmV({K+?Y-iwFP!000021MI!ojwDx>B={?wcy@E#eaW}Rpl4nZjR9)riO2_bC$oxE z%w&ROS5H+T|9!5AoU5j4TUdmfdALVd7K)^+o2%(^mviLE5&6##zyI@ZAO7H2<{lg!|zkV72@@@Rb|N8KU?8DQCf3bi6?XTmH{x|*a zKmOxC#$W#V&wu8h{P5dvKmI;W{QaMvKK%T{zyJ6v{`)_dU;p&C@$Yeh-~au?AEKS- zzx_Xd{Ns23^uPa?KmOP6{>vZ#IL`Ikue`8-9e?_tAAZ3-gtCo<972bL9Jw z<8yu*|9oUvwS&8w_du$8W#$=l}j!e#OD{MqO{R>;0#H z`Qh(Be)mV*+;@3g?%#j-^~b;c&fD5t`MgfAEBVh4KjFatH_rRq;yCBO+kcI#`epfz zFV%iC{%ibZoVO(2)%QDJ{c{TO$=Cjz6Yh8Xe*Ee8DL?mdd>8t&o5#nOeea$eAIfKc zbr18abLCf;u>Xc%mT$1H^M#*j<+v;M<{jO*GH@z_a;>?CeAO%<_?c<448=4fsrcuBqcpW!$k#Pu}A=KKDO9{NeXs z|L2eYc*zT_ZFB8A@17Gb%sqz~#<%+LT*H_>>ACc>T)#fGd}wE%+U-B4dkFQpHC%BD z2`4Mhp@gS#_p#Pn-N77!%hOL`JW3ZmFKk?5jd=WZ`}oI)cJovAq2)8Y_LiPQ9xu-O z_}mL#KrPQLjU#=G;+&5ze?5@xTEcT2k7?XR!rhOz-Etpinw)c<9S{lEP1^KU=$LNNpW z^79A&$$Dzxp|%og%C5I@*=2l}e)dn}|Nb=J#PSzo%^b(Hq;X}m@BKf1`uXQG-}^hJ z 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
f}(`u3w- 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 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index a7dce56b2e..fd14a86226 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -483,7 +483,7 @@ public final class AccountContextImpl: AccountContext { let context = chatLocationContext(holder: contextHolder, account: self.account, data: data) return .thread(peerId: data.peerId, threadId: data.threadId, data: context.state) } - case .feed: + case .customChatContents: preconditionFailure() } } @@ -509,7 +509,7 @@ public final class AccountContextImpl: AccountContext { } else { return .single(nil) } - case .feed: + case .customChatContents: return .single(nil) } } @@ -547,7 +547,7 @@ public final class AccountContextImpl: AccountContext { let context = chatLocationContext(holder: contextHolder, account: self.account, data: data) return context.unreadCount } - case .feed: + case .customChatContents: return .single(0) } } @@ -559,7 +559,7 @@ public final class AccountContextImpl: AccountContext { case let .replyThread(data): let context = chatLocationContext(holder: contextHolder, account: self.account, data: data) context.applyMaxReadIndex(messageIndex: messageIndex) - case .feed: + case .customChatContents: break } } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift index 8e0e5136e5..448e33897c 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift @@ -289,7 +289,7 @@ extension ChatControllerImpl { if let location = location { source = .location(ChatMessageContextLocationContentSource(controller: self, location: node.view.convert(node.bounds, to: nil).origin.offsetBy(dx: location.x, dy: location.y))) } else { - source = .extracted(ChatMessageContextExtractedContentSource(chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, selectAll: selectAll)) + source = .extracted(ChatMessageContextExtractedContentSource(chatController: self, chatNode: self.chatDisplayNode, engine: self.context.engine, message: message, selectAll: selectAll)) } self.canReadHistory.set(false) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 6ea3fb7ff7..ed6e37e4f0 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -137,7 +137,7 @@ public final class ChatControllerOverlayPresentationData { enum ChatLocationInfoData { case peer(Promise) case replyThread(Promise) - case feed + case customChatContents } enum ChatRecordingActivity { @@ -647,10 +647,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G promise.set(.single(nil)) } self.chatLocationInfoData = .replyThread(promise) - case .feed: + case .customChatContents: locationBroadcastPanelSource = .none groupCallPanelSource = .none - self.chatLocationInfoData = .feed + self.chatLocationInfoData = .customChatContents } self.presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -2296,7 +2296,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } case .replyThread: postAsReply = true - case .feed: + case .customChatContents: postAsReply = true } @@ -2902,7 +2902,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case let .replyThread(replyThreadMessage): let peerId = replyThreadMessage.peerId strongSelf.navigateToMessage(from: nil, to: .index(MessageIndex(id: MessageId(peerId: peerId, namespace: 0, id: 0), timestamp: timestamp - Int32(NSTimeZone.local.secondsFromGMT()))), scrollPosition: .bottom(0.0), rememberInStack: false, forceInCurrentChat: true, animated: true, completion: nil) - case .feed: + case .customChatContents: break } }, requestRedeliveryOfFailedMessages: { [weak self] id in @@ -2943,7 +2943,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - let controller = ContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: message._asMessage(), selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) + let controller = ContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatController: strongSelf, chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: message._asMessage(), selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) strongSelf.currentContextController = controller strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { @@ -3021,7 +3021,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.chatDisplayNode.messageTransitionNode.dismissMessageReactionContexts() - let controller = ContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: topMessage, selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) + let controller = ContextController(presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatController: strongSelf, chatNode: strongSelf.chatDisplayNode, engine: strongSelf.context.engine, message: topMessage, selectAll: true)), items: .single(ContextController.Items(content: .list(actions))), recognizer: nil) strongSelf.currentContextController = controller strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { @@ -4787,7 +4787,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G chatInfoButtonItem = UIBarButtonItem(customDisplayNode: avatarNode)! self.avatarNode = avatarNode - case .feed: + case .customChatContents: chatInfoButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) } chatInfoButtonItem.target = self @@ -5669,7 +5669,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G replyThreadType = .replies } } - case .feed: + case .customChatContents: replyThreadType = .replies } @@ -6149,11 +6149,23 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } })) - } else if case .feed = self.chatLocationInfoData { + } else if case .customChatContents = self.chatLocationInfoData { self.reportIrrelvantGeoNoticePromise.set(.single(nil)) self.titleDisposable.set(nil) - self.chatTitleView?.titleContent = .custom("Feed", nil, false) + //TODO:localize + if case let .customChatContents(customChatContents) = self.subject { + switch customChatContents.kind { + case .greetingMessageInput: + self.chatTitleView?.titleContent = .custom("Greeting Message", nil, false) + case .awayMessageInput: + self.chatTitleView?.titleContent = .custom("Away Message", nil, false) + case let .quickReplyMessageInput(shortcut): + self.chatTitleView?.titleContent = .custom("/\(shortcut)", nil, false) + } + } else { + self.chatTitleView?.titleContent = .custom("Messages", nil, false) + } if !self.didSetChatLocationInfoReady { self.didSetChatLocationInfoReady = true @@ -6307,7 +6319,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G activitySpace = PeerActivitySpace(peerId: peerId, category: .global) case let .replyThread(replyThreadMessage): activitySpace = PeerActivitySpace(peerId: replyThreadMessage.peerId, category: .thread(replyThreadMessage.threadId)) - case .feed: + case .customChatContents: activitySpace = nil } @@ -7763,7 +7775,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case .peer: pinnedMessageId = topPinnedMessage?.message.id pinnedMessage = topPinnedMessage - case .feed: + case .customChatContents: pinnedMessageId = nil pinnedMessage = nil } @@ -7934,7 +7946,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G $0.updatedChatHistoryState(state) }) - if let botStart = strongSelf.botStart, case let .loaded(isEmpty) = state { + if let botStart = strongSelf.botStart, case let .loaded(isEmpty, _) = state { strongSelf.botStart = nil if !isEmpty { strongSelf.startBot(botStart.payload) @@ -8171,20 +8183,24 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } self.chatDisplayNode.sendMessages = { [weak self] messages, silentPosting, scheduleTime, isAnyMessageTextPartitioned in - if let strongSelf = self, let peerId = strongSelf.chatLocation.peerId { - var correlationIds: [Int64] = [] - for message in messages { - switch message { - case let .message(_, _, _, _, _, _, _, _, correlationId, _): - if let correlationId = correlationId { - correlationIds.append(correlationId) - } - default: - break + guard let strongSelf = self else { + return + } + + var correlationIds: [Int64] = [] + for message in messages { + switch message { + case let .message(_, _, _, _, _, _, _, _, correlationId, _): + if let correlationId = correlationId { + correlationIds.append(correlationId) } + default: + break } - strongSelf.commitPurposefulAction() - + } + strongSelf.commitPurposefulAction() + + if let peerId = strongSelf.chatLocation.peerId { var hasDisabledContent = false if "".isEmpty { hasDisabledContent = false @@ -8271,9 +8287,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) donateSendMessageIntent(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, intentContext: .chat, peerIds: [peerId]) - - strongSelf.updateChatPresentationInterfaceState(interactive: true, { $0.updatedShowCommands(false) }) + } else if case let .customChatContents(customChatContents) = strongSelf.subject { + customChatContents.enqueueMessages(messages: messages) + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() } + + strongSelf.updateChatPresentationInterfaceState(interactive: true, { $0.updatedShowCommands(false) }) } self.chatDisplayNode.requestUpdateChatInterfaceState = { [weak self] transition, saveInterfaceState, f in @@ -9066,7 +9085,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } - let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: editMessage.messageId)) + let sourceMessage: Signal + if case let .customChatContents(customChatContents) = strongSelf.subject { + sourceMessage = customChatContents.messages + |> take(1) + |> map { messages -> EngineMessage? in + return messages.first(where: { $0.id == editMessage.messageId }).flatMap(EngineMessage.init) + } + } else { + sourceMessage = strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Messages.Message(id: editMessage.messageId)) + } + + let _ = (sourceMessage |> deliverOnMainQueue).start(next: { [weak strongSelf] message in guard let strongSelf, let message else { return @@ -9161,27 +9191,37 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G media = .keep } - let _ = (strongSelf.context.account.postbox.messageAtId(editMessage.messageId) - |> deliverOnMainQueue) - .startStandalone(next: { [weak self] currentMessage in - if let strongSelf = self { - if let currentMessage = currentMessage { - let currentEntities = currentMessage.textEntitiesAttribute?.entities ?? [] - let currentWebpagePreviewAttribute = currentMessage.webpagePreviewAttribute ?? WebpagePreviewMessageAttribute(leadingPreview: false, forceLargeMedia: nil, isManuallyAdded: true, isSafe: false) - - if currentMessage.text != text.string || currentEntities != entities || updatingMedia || webpagePreviewAttribute != currentWebpagePreviewAttribute || disableUrlPreview { - strongSelf.context.account.pendingUpdateMessageManager.add(messageId: editMessage.messageId, text: text.string, media: media, entities: entitiesAttribute, inlineStickers: inlineStickers, webpagePreviewAttribute: webpagePreviewAttribute, disableUrlPreview: disableUrlPreview) + if case let .customChatContents(customChatContents) = strongSelf.subject { + customChatContents.editMessage(id: editMessage.messageId, text: text.string, media: media, entities: entitiesAttribute, webpagePreviewAttribute: webpagePreviewAttribute, disableUrlPreview: disableUrlPreview) + + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + var state = state + state = state.updatedInterfaceState({ $0.withUpdatedEditMessage(nil) }) + state = state.updatedEditMessageState(nil) + return state + }) + } else { + let _ = (strongSelf.context.account.postbox.messageAtId(editMessage.messageId) + |> deliverOnMainQueue).startStandalone(next: { [weak self] currentMessage in + if let strongSelf = self { + if let currentMessage = currentMessage { + let currentEntities = currentMessage.textEntitiesAttribute?.entities ?? [] + let currentWebpagePreviewAttribute = currentMessage.webpagePreviewAttribute ?? WebpagePreviewMessageAttribute(leadingPreview: false, forceLargeMedia: nil, isManuallyAdded: true, isSafe: false) + + if currentMessage.text != text.string || currentEntities != entities || updatingMedia || webpagePreviewAttribute != currentWebpagePreviewAttribute || disableUrlPreview { + strongSelf.context.account.pendingUpdateMessageManager.add(messageId: editMessage.messageId, text: text.string, media: media, entities: entitiesAttribute, inlineStickers: inlineStickers, webpagePreviewAttribute: webpagePreviewAttribute, disableUrlPreview: disableUrlPreview) + } } + + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in + var state = state + state = state.updatedInterfaceState({ $0.withUpdatedEditMessage(nil) }) + state = state.updatedEditMessageState(nil) + return state + }) } - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { state in - var state = state - state = state.updatedInterfaceState({ $0.withUpdatedEditMessage(nil) }) - state = state.updatedEditMessageState(nil) - return state - }) - } - }) + }) + } }) }, beginMessageSearch: { [weak self] domain, query in guard let strongSelf = self else { @@ -9313,7 +9353,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.updateItemNodesSearchTextHighlightStates() if let navigateIndex = navigateIndex { switch strongSelf.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: strongSelf.navigateToMessage(from: nil, to: .index(navigateIndex), forceInCurrentChat: true) } } @@ -11249,7 +11289,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G activitySpace = PeerActivitySpace(peerId: peerId, category: .global) case let .replyThread(replyThreadMessage): activitySpace = PeerActivitySpace(peerId: replyThreadMessage.peerId, category: .thread(replyThreadMessage.threadId)) - case .feed: + case .customChatContents: activitySpace = nil } @@ -12152,7 +12192,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G peerId = replyThreadMessage.peerId threadId = replyThreadMessage.threadId } - case .feed: + case .customChatContents: return } @@ -12710,7 +12750,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.effectiveNavigationController?.pushViewController(infoController) } } - case .feed: + case .customChatContents: break } }) @@ -12922,7 +12962,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G })) case .replyThread: break - case .feed: + case .customChatContents: break } } @@ -13654,7 +13694,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let effectiveMessageId = replyThreadMessage.effectiveMessageId { defaultReplyMessageSubject = EngineMessageReplySubject(messageId: effectiveMessageId, quote: nil) } - case .feed: + case .customChatContents: break } @@ -13709,6 +13749,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } func sendMessages(_ messages: [EnqueueMessage], media: Bool = false, commit: Bool = false) { + if case let .customChatContents(customChatContents) = self.subject { + customChatContents.enqueueMessages(messages: messages) + return + } + guard let peerId = self.chatLocation.peerId else { return } diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index e81b8dfbc0..ad8d2d7231 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -588,6 +588,20 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { } source = .custom(messages: messages, messageId: MessageId(peerId: PeerId(0), namespace: 0, id: 0), quote: nil, loadMore: nil) } + } else if case .customChatContents = chatLocation { + if case let .customChatContents(customChatContents) = subject { + source = .custom( + messages: customChatContents.messages + |> map { messages in + return (messages, 0, false) + }, + messageId: nil, + quote: nil, + loadMore: nil + ) + } else { + source = .custom(messages: .single(([], 0, false)), messageId: nil, quote: nil, loadMore: nil) + } } else { source = .default } diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index f519a12098..310f14acef 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -39,10 +39,6 @@ extension ChatControllerImpl { } func presentAttachmentMenu(subject: AttachMenuSubject) { - guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } - let context = self.context let inputIsActive = self.presentationInterfaceState.inputMode == .text @@ -56,42 +52,46 @@ extension ChatControllerImpl { var bannedSendFiles: (Int32, Bool)? var canSendPolls = true - if let peer = peer as? TelegramUser, peer.botInfo == nil { - canSendPolls = false - } else if peer is TelegramSecretChat { - canSendPolls = false - } else if let channel = peer as? TelegramChannel { - if let value = channel.hasBannedPermission(.banSendPhotos, ignoreDefault: canByPassRestrictions) { - bannedSendPhotos = value - } - if let value = channel.hasBannedPermission(.banSendVideos, ignoreDefault: canByPassRestrictions) { - bannedSendVideos = value - } - if let value = channel.hasBannedPermission(.banSendFiles, ignoreDefault: canByPassRestrictions) { - bannedSendFiles = value - } - if let value = channel.hasBannedPermission(.banSendText, ignoreDefault: canByPassRestrictions) { - banSendText = value - } - if channel.hasBannedPermission(.banSendPolls, ignoreDefault: canByPassRestrictions) != nil { + if let peer = self.presentationInterfaceState.renderedPeer?.peer { + if let peer = peer as? TelegramUser, peer.botInfo == nil { canSendPolls = false - } - } else if let group = peer as? TelegramGroup { - if group.hasBannedPermission(.banSendPhotos) { - bannedSendPhotos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendVideos) { - bannedSendVideos = (Int32.max, false) - } - if group.hasBannedPermission(.banSendFiles) { - bannedSendFiles = (Int32.max, false) - } - if group.hasBannedPermission(.banSendText) { - banSendText = (Int32.max, false) - } - if group.hasBannedPermission(.banSendPolls) { + } else if peer is TelegramSecretChat { canSendPolls = false + } else if let channel = peer as? TelegramChannel { + if let value = channel.hasBannedPermission(.banSendPhotos, ignoreDefault: canByPassRestrictions) { + bannedSendPhotos = value + } + if let value = channel.hasBannedPermission(.banSendVideos, ignoreDefault: canByPassRestrictions) { + bannedSendVideos = value + } + if let value = channel.hasBannedPermission(.banSendFiles, ignoreDefault: canByPassRestrictions) { + bannedSendFiles = value + } + if let value = channel.hasBannedPermission(.banSendText, ignoreDefault: canByPassRestrictions) { + banSendText = value + } + if channel.hasBannedPermission(.banSendPolls, ignoreDefault: canByPassRestrictions) != nil { + canSendPolls = false + } + } else if let group = peer as? TelegramGroup { + if group.hasBannedPermission(.banSendPhotos) { + bannedSendPhotos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendVideos) { + bannedSendVideos = (Int32.max, false) + } + if group.hasBannedPermission(.banSendFiles) { + bannedSendFiles = (Int32.max, false) + } + if group.hasBannedPermission(.banSendText) { + banSendText = (Int32.max, false) + } + if group.hasBannedPermission(.banSendPolls) { + canSendPolls = false + } } + } else { + canSendPolls = false } var availableButtons: [AttachmentButtonType] = [.gallery, .file] @@ -111,24 +111,26 @@ extension ChatControllerImpl { } var peerType: AttachMenuBots.Bot.PeerFlags = [] - if let user = peer as? TelegramUser { - if let _ = user.botInfo { - peerType.insert(.bot) - } else { - peerType.insert(.user) - } - } else if let _ = peer as? TelegramGroup { - peerType = .group - } else if let channel = peer as? TelegramChannel { - if case .broadcast = channel.info { - peerType = .channel - } else { + if let peer = self.presentationInterfaceState.renderedPeer?.peer { + if let user = peer as? TelegramUser { + if let _ = user.botInfo { + peerType.insert(.bot) + } else { + peerType.insert(.user) + } + } else if let _ = peer as? TelegramGroup { peerType = .group + } else if let channel = peer as? TelegramChannel { + if case .broadcast = channel.info { + peerType = .channel + } else { + peerType = .group + } } } let buttons: Signal<([AttachmentButtonType], [AttachmentButtonType], AttachmentButtonType?), NoError> - if !isScheduledMessages && !peer.isDeleted { + if let peer = self.presentationInterfaceState.renderedPeer?.peer, !isScheduledMessages, !peer.isDeleted { buttons = self.context.engine.messages.attachMenuBots() |> map { attachMenuBots in var buttons = availableButtons @@ -177,7 +179,7 @@ extension ChatControllerImpl { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 }) let premiumGiftOptions: [CachedPremiumGiftOption] - if !premiumConfiguration.isPremiumDisabled && premiumConfiguration.showPremiumGiftInAttachMenu, let user = peer as? TelegramUser, !user.isPremium && !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport) { + if let peer = self.presentationInterfaceState.renderedPeer?.peer, !premiumConfiguration.isPremiumDisabled, premiumConfiguration.showPremiumGiftInAttachMenu, let user = peer as? TelegramUser, !user.isPremium && !user.isDeleted && user.botInfo == nil && !user.flags.contains(.isSupport) { premiumGiftOptions = self.presentationInterfaceState.premiumGiftOptions } else { premiumGiftOptions = [] @@ -324,20 +326,30 @@ extension ChatControllerImpl { return } let selfPeerId: PeerId - if let peer = peer as? TelegramChannel, case .broadcast = peer.info { - selfPeerId = peer.id - } else if let peer = peer as? TelegramChannel, case .group = peer.info, peer.hasPermission(.canBeAnonymous) { - selfPeerId = peer.id + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { + if let peer = peer as? TelegramChannel, case .broadcast = peer.info { + selfPeerId = peer.id + } else if let peer = peer as? TelegramChannel, case .group = peer.info, peer.hasPermission(.canBeAnonymous) { + selfPeerId = peer.id + } else { + selfPeerId = strongSelf.context.account.peerId + } } else { selfPeerId = strongSelf.context.account.peerId } let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: selfPeerId)) - |> deliverOnMainQueue).startStandalone(next: { [weak self] selfPeer in + |> deliverOnMainQueue).startStandalone(next: { selfPeer in guard let strongSelf = self, let selfPeer = selfPeer else { return } - let hasLiveLocation = peer.id.namespace != Namespaces.Peer.SecretChat && peer.id != strongSelf.context.account.peerId && strongSelf.presentationInterfaceState.subject != .scheduledMessages - let controller = LocationPickerController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, mode: .share(peer: EnginePeer(peer), selfPeer: selfPeer, hasLiveLocation: hasLiveLocation), completion: { [weak self] location, _, _, _, _ in + let hasLiveLocation: Bool + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { + hasLiveLocation = peer.id.namespace != Namespaces.Peer.SecretChat && peer.id != strongSelf.context.account.peerId && strongSelf.presentationInterfaceState.subject != .scheduledMessages + } else { + hasLiveLocation = false + } + let sharePeer = (strongSelf.presentationInterfaceState.renderedPeer?.peer).flatMap(EnginePeer.init) + let controller = LocationPickerController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, mode: .share(peer: sharePeer, selfPeer: selfPeer, hasLiveLocation: hasLiveLocation), completion: { location, _, _, _, _ in guard let strongSelf = self else { return } @@ -523,69 +535,73 @@ extension ChatControllerImpl { completion(controller, controller?.mediaPickerContext) strongSelf.controllerNavigationDisposable.set(nil) case .gift: - let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions - if !premiumGiftOptions.isEmpty { - let controller = PremiumGiftAttachmentScreen(context: context, peerIds: [peer.id], options: premiumGiftOptions, source: .attachMenu, pushController: { [weak self] c in + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { + let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions + if !premiumGiftOptions.isEmpty { + let controller = PremiumGiftAttachmentScreen(context: context, peerIds: [peer.id], options: premiumGiftOptions, source: .attachMenu, pushController: { [weak self] c in + if let strongSelf = self { + strongSelf.push(c) + } + }, completion: { [weak self] in + if let strongSelf = self { + strongSelf.hintPlayNextOutgoingGift() + strongSelf.attachmentController?.dismiss(animated: true) + } + }) + completion(controller, controller.mediaPickerContext) + strongSelf.controllerNavigationDisposable.set(nil) + + let _ = ApplicationSpecificNotice.incrementDismissedPremiumGiftSuggestion(accountManager: context.sharedContext.accountManager, peerId: peer.id).startStandalone() + } + } + case let .app(bot): + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer { + var payload: String? + var fromAttachMenu = true + if case let .bot(_, botPayload, _) = subject { + payload = botPayload + fromAttachMenu = false + } + let params = WebAppParameters(source: fromAttachMenu ? .attachMenu : .generic, peerId: peer.id, botId: bot.peer.id, botName: bot.shortName, url: nil, queryId: nil, payload: payload, buttonText: nil, keepAliveSignal: nil, forceHasSettings: false) + let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject + let controller = WebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, replyToMessageId: replyMessageSubject?.messageId, threadId: strongSelf.chatLocation.threadId) + controller.openUrl = { [weak self] url, concealed, commit in + self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) + } + controller.getNavigationController = { [weak self] in + return self?.effectiveNavigationController + } + controller.completion = { [weak self] in if let strongSelf = self { - strongSelf.push(c) + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) } + }) + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() } - }, completion: { [weak self] in - if let strongSelf = self { - strongSelf.hintPlayNextOutgoingGift() - strongSelf.attachmentController?.dismiss(animated: true) - } - }) + } completion(controller, controller.mediaPickerContext) strongSelf.controllerNavigationDisposable.set(nil) - let _ = ApplicationSpecificNotice.incrementDismissedPremiumGiftSuggestion(accountManager: context.sharedContext.accountManager, peerId: peer.id).startStandalone() - } - case let .app(bot): - var payload: String? - var fromAttachMenu = true - if case let .bot(_, botPayload, _) = subject { - payload = botPayload - fromAttachMenu = false - } - let params = WebAppParameters(source: fromAttachMenu ? .attachMenu : .generic, peerId: peer.id, botId: bot.peer.id, botName: bot.shortName, url: nil, queryId: nil, payload: payload, buttonText: nil, keepAliveSignal: nil, forceHasSettings: false) - let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject - let controller = WebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, replyToMessageId: replyMessageSubject?.messageId, threadId: strongSelf.chatLocation.threadId) - controller.openUrl = { [weak self] url, concealed, commit in - self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) - } - controller.getNavigationController = { [weak self] in - return self?.effectiveNavigationController - } - controller.completion = { [weak self] in - if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil) } + if bot.flags.contains(.notActivated) { + let alertController = webAppTermsAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, bot: bot, completion: { [weak self] allowWrite in + guard let self else { + return + } + if bot.flags.contains(.showInSettingsDisclaimer) { + let _ = self.context.engine.messages.acceptAttachMenuBotDisclaimer(botId: bot.peer.id).startStandalone() + } + let _ = (self.context.engine.messages.addBotToAttachMenu(botId: bot.peer.id, allowWrite: allowWrite) + |> deliverOnMainQueue).startStandalone(error: { _ in + }, completed: { [weak controller] in + controller?.refresh() + }) + }, + dismissed: { + strongSelf.attachmentController?.dismiss(animated: true) }) - strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() + strongSelf.present(alertController, in: .window(.root)) } } - completion(controller, controller.mediaPickerContext) - strongSelf.controllerNavigationDisposable.set(nil) - - if bot.flags.contains(.notActivated) { - let alertController = webAppTermsAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, bot: bot, completion: { [weak self] allowWrite in - guard let self else { - return - } - if bot.flags.contains(.showInSettingsDisclaimer) { - let _ = self.context.engine.messages.acceptAttachMenuBotDisclaimer(botId: bot.peer.id).startStandalone() - } - let _ = (self.context.engine.messages.addBotToAttachMenu(botId: bot.peer.id, allowWrite: allowWrite) - |> deliverOnMainQueue).startStandalone(error: { _ in - }, completed: { [weak controller] in - controller?.refresh() - }) - }, - dismissed: { - strongSelf.attachmentController?.dismiss(animated: true) - }) - strongSelf.present(alertController, in: .window(.root)) - } default: break } @@ -1063,9 +1079,6 @@ extension ChatControllerImpl { } func presentMediaPicker(subject: MediaPickerScreen.Subject = .assets(nil, .default), saveEditedPhotos: Bool, bannedSendPhotos: (Int32, Bool)?, bannedSendVideos: (Int32, Bool)?, present: @escaping (MediaPickerScreen, AttachmentMediaPickerContext?) -> Void, updateMediaPickerContext: @escaping (AttachmentMediaPickerContext?) -> Void, completion: @escaping ([Any], Bool, Int32?, @escaping (String) -> UIView?, @escaping () -> Void) -> Void) { - guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } var isScheduledMessages = false if case .scheduledMessages = self.presentationInterfaceState.subject { isScheduledMessages = true @@ -1073,7 +1086,7 @@ extension ChatControllerImpl { let controller = MediaPickerScreen( context: self.context, updatedPresentationData: self.updatedPresentationData, - peer: EnginePeer(peer), + peer: (self.presentationInterfaceState.renderedPeer?.peer).flatMap(EnginePeer.init), threadTitle: self.threadInfo?.title, chatLocation: self.chatLocation, isScheduledMessages: isScheduledMessages, diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenCalendarSearch.swift b/submodules/TelegramUI/Sources/ChatControllerOpenCalendarSearch.swift index b29df6f941..62ae3df62d 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenCalendarSearch.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenCalendarSearch.swift @@ -59,7 +59,7 @@ extension ChatControllerImpl { case let .replyThread(replyThreadMessage): peerId = replyThreadMessage.peerId threadId = replyThreadMessage.threadId - case .feed: + case .customChatContents: return } @@ -96,7 +96,7 @@ extension ChatControllerImpl { case let .replyThread(replyThreadMessage): peerId = replyThreadMessage.peerId threadId = replyThreadMessage.threadId - case .feed: + case .customChatContents: return } diff --git a/submodules/TelegramUI/Sources/ChatControllerUpdateSearch.swift b/submodules/TelegramUI/Sources/ChatControllerUpdateSearch.swift index 75d9bbc33f..59f3755db3 100644 --- a/submodules/TelegramUI/Sources/ChatControllerUpdateSearch.swift +++ b/submodules/TelegramUI/Sources/ChatControllerUpdateSearch.swift @@ -33,7 +33,7 @@ extension ChatControllerImpl { break case let .replyThread(replyThreadMessage): threadId = replyThreadMessage.threadId - case .feed: + case .customChatContents: break } @@ -125,7 +125,7 @@ extension ChatControllerImpl { }) if let navigateIndex = navigateIndex { switch strongSelf.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: strongSelf.navigateToMessage(from: nil, to: .index(navigateIndex), forceInCurrentChat: true) } } diff --git a/submodules/TelegramUI/Sources/ChatEmptyNode.swift b/submodules/TelegramUI/Sources/ChatEmptyNode.swift index 5983a2c37a..6098927bc0 100644 --- a/submodules/TelegramUI/Sources/ChatEmptyNode.swift +++ b/submodules/TelegramUI/Sources/ChatEmptyNode.swift @@ -53,7 +53,7 @@ private final class ChatEmptyNodeRegularChatContent: ASDisplayNode, ChatEmptyNod text = interfaceState.strings.ChatList_StartMessaging } else { switch interfaceState.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: if case .scheduledMessages = interfaceState.subject { text = interfaceState.strings.ScheduledMessages_EmptyPlaceholder } else { @@ -701,6 +701,12 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC } func updateLayout(interfaceState: ChatPresentationInterfaceState, subject: ChatEmptyNode.Subject, size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { + var maxWidth: CGFloat = size.width + var centerText = false + if case .customChatContents = interfaceState.subject { + maxWidth = min(240.0, maxWidth) + } + if self.currentTheme !== interfaceState.theme || self.currentStrings !== interfaceState.strings { self.currentTheme = interfaceState.theme self.currentStrings = interfaceState.strings @@ -709,17 +715,56 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Empty Chat/Cloud"), color: serviceColor.primaryText) - let titleString = interfaceState.strings.Conversation_CloudStorageInfo_Title + let titleString: String + let strings: [String] + + if case let .customChatContents(customChatContents) = interfaceState.subject { + switch customChatContents.kind { + case .greetingMessageInput: + //TODO:localize + centerText = true + titleString = "New Greeting Message" + strings = [ + "Create greetings that will be automatically sent to new customers" + ] + case .awayMessageInput: + //TODO:localize + centerText = true + titleString = "New Away Message" + strings = [ + "Add messages that are automatically sent when you are off." + ] + case let .quickReplyMessageInput(shortcut): + //TODO:localize + centerText = false + titleString = "New Quick Reply" + strings = [ + "Enter a message below that will be sent in chats when you type \"**/\(shortcut)\"**.", + "You can access Quick Replies in any chat by typing \"/\" or using the Attachment menu." + ] + } + } else { + titleString = interfaceState.strings.Conversation_CloudStorageInfo_Title + strings = [ + interfaceState.strings.Conversation_ClousStorageInfo_Description1, + interfaceState.strings.Conversation_ClousStorageInfo_Description2, + interfaceState.strings.Conversation_ClousStorageInfo_Description3, + interfaceState.strings.Conversation_ClousStorageInfo_Description4 + ] + } + self.titleNode.attributedText = NSAttributedString(string: titleString, font: titleFont, textColor: serviceColor.primaryText) - let strings: [String] = [ - interfaceState.strings.Conversation_ClousStorageInfo_Description1, - interfaceState.strings.Conversation_ClousStorageInfo_Description2, - interfaceState.strings.Conversation_ClousStorageInfo_Description3, - interfaceState.strings.Conversation_ClousStorageInfo_Description4 - ] - - let lines: [NSAttributedString] = strings.map { NSAttributedString(string: $0, font: messageFont, textColor: serviceColor.primaryText) } + let lines: [NSAttributedString] = strings.map { + return parseMarkdownIntoAttributedString($0, attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(14.0), textColor: serviceColor.primaryText), + bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: serviceColor.primaryText), + link: MarkdownAttributeSet(font: Font.regular(14.0), textColor: serviceColor.primaryText), + linkAttribute: { url in + return ("URL", url) + } + ), textAlignment: centerText ? .center : .natural) + } for i in 0 ..< lines.count { if i >= self.lineNodes.count { @@ -727,6 +772,7 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC textNode.maximumNumberOfLines = 0 textNode.isUserInteractionEnabled = false textNode.displaysAsynchronously = false + textNode.textAlignment = centerText ? .center : .natural self.addSubnode(textNode) self.lineNodes.append(textNode) } @@ -751,7 +797,7 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC var lineNodes: [(CGSize, ImmediateTextNode)] = [] for textNode in self.lineNodes { - let textSize = textNode.updateLayout(CGSize(width: size.width - insets.left - insets.right - 10.0, height: CGFloat.greatestFiniteMagnitude)) + let textSize = textNode.updateLayout(CGSize(width: maxWidth - insets.left - insets.right - 10.0, height: CGFloat.greatestFiniteMagnitude)) contentWidth = max(contentWidth, textSize.width) contentHeight += textSize.height + titleSpacing lineNodes.append((textSize, textNode)) @@ -1166,7 +1212,9 @@ final class ChatEmptyNode: ASDisplayNode { case .detailsPlaceholder: contentType = .regular case let .emptyChat(emptyType): - if case .replyThread = interfaceState.chatLocation { + if case .customChatContents = interfaceState.subject { + contentType = .cloud + } else if case .replyThread = interfaceState.chatLocation { if case .topic = emptyType { contentType = .topic } else { diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index cfa43ce97c..517644b8fe 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -212,13 +212,18 @@ extension ListMessageItemInteraction { } private func mappedInsertEntries(context: AccountContext, chatLocation: ChatLocation, associatedData: ChatMessageItemAssociatedData, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, lastHeaderId: Int64, entries: [ChatHistoryViewTransitionInsertEntry]) -> [ListViewInsertItem] { + var disableFloatingDateHeaders = false + if case .customChatContents = chatLocation { + disableFloatingDateHeaders = true + } + return entries.map { entry -> ListViewInsertItem in switch entry.entry { case let .MessageEntry(message, presentationData, read, location, selection, attributes): let item: ListViewItem switch mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location)) + item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location), disableDate: disableFloatingDateHeaders) case let .list(_, _, _, displayHeaders, hintLinks, isGlobalSearch): let displayHeader: Bool switch displayHeaders { @@ -236,7 +241,7 @@ private func mappedInsertEntries(context: AccountContext, chatLocation: ChatLoca let item: ListViewItem switch mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .group(messages: messages)) + item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .group(messages: messages), disableDate: disableFloatingDateHeaders) case .list: assertionFailure() item = ListMessageItem(presentationData: presentationData, context: context, chatLocation: chatLocation, interaction: ListMessageItemInteraction(controllerInteraction: controllerInteraction), message: messages[0].0, selection: .none, displayHeader: false) @@ -257,13 +262,18 @@ private func mappedInsertEntries(context: AccountContext, chatLocation: ChatLoca } private func mappedUpdateEntries(context: AccountContext, chatLocation: ChatLocation, associatedData: ChatMessageItemAssociatedData, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, lastHeaderId: Int64, entries: [ChatHistoryViewTransitionUpdateEntry]) -> [ListViewUpdateItem] { + var disableFloatingDateHeaders = false + if case .customChatContents = chatLocation { + disableFloatingDateHeaders = true + } + return entries.map { entry -> ListViewUpdateItem in switch entry.entry { case let .MessageEntry(message, presentationData, read, location, selection, attributes): let item: ListViewItem switch mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location)) + item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location), disableDate: disableFloatingDateHeaders) case let .list(_, _, _, displayHeaders, hintLinks, isGlobalSearch): let displayHeader: Bool switch displayHeaders { @@ -281,7 +291,7 @@ private func mappedUpdateEntries(context: AccountContext, chatLocation: ChatLoca let item: ListViewItem switch mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .group(messages: messages)) + item = ChatMessageItemImpl(presentationData: presentationData, context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, content: .group(messages: messages), disableDate: disableFloatingDateHeaders) case .list: assertionFailure() item = ListMessageItem(presentationData: presentationData, context: context, chatLocation: chatLocation, interaction: ListMessageItemInteraction(controllerInteraction: controllerInteraction), message: messages[0].0, selection: .none, displayHeader: false) @@ -2011,10 +2021,12 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto } if apply { switch strongSelf.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread: if !strongSelf.context.sharedContext.immediateExperimentalUISettings.skipReadHistory { strongSelf.context.applyMaxReadIndex(for: strongSelf.chatLocation, contextHolder: strongSelf.chatLocationContextHolder, messageIndex: messageIndex) } + case .customChatContents: + break } } }).strict()) @@ -2757,7 +2769,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto switch self.chatLocation { case .peer: messageIndex = maxIncomingIndex - case .replyThread, .feed: + case .replyThread, .customChatContents: messageIndex = maxOverallIndex } @@ -3142,7 +3154,13 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto } let isEmpty = transition.historyView.originalView.entries.isEmpty || loadState == .empty(.botInfo) - let historyState: ChatHistoryNodeHistoryState = .loaded(isEmpty: isEmpty) + + var hasReachedLimits = false + if case let .customChatContents(customChatContents) = self.subject, let messageLimit = customChatContents.messageLimit { + hasReachedLimits = transition.historyView.originalView.entries.count >= messageLimit + } + + let historyState: ChatHistoryNodeHistoryState = .loaded(isEmpty: isEmpty, hasReachedLimits: hasReachedLimits) if self.currentHistoryState != historyState { self.currentHistoryState = historyState self.historyState.set(historyState) @@ -3403,7 +3421,14 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto strongSelf.historyView = transition.historyView let loadState: ChatHistoryNodeLoadState + var alwaysHasMessages = false if case .custom = strongSelf.source { + if case .customChatContents = strongSelf.chatLocation { + } else { + alwaysHasMessages = true + } + } + if alwaysHasMessages { loadState = .messages } else if let historyView = strongSelf.historyView { if historyView.filteredEntries.isEmpty { @@ -3513,7 +3538,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto switch strongSelf.chatLocation { case .peer: messageIndex = incomingIndex - case .replyThread, .feed: + case .replyThread, .customChatContents: messageIndex = overallIndex } @@ -3534,7 +3559,11 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto } strongSelf._cachedPeerDataAndMessages.set(.single((transition.cachedData, transition.cachedDataMessages))) let isEmpty = transition.historyView.originalView.entries.isEmpty || loadState == .empty(.botInfo) - let historyState: ChatHistoryNodeHistoryState = .loaded(isEmpty: isEmpty) + var hasReachedLimits = false + if case let .customChatContents(customChatContents) = strongSelf.subject, let messageLimit = customChatContents.messageLimit { + hasReachedLimits = transition.historyView.originalView.entries.count >= messageLimit + } + let historyState: ChatHistoryNodeHistoryState = .loaded(isEmpty: isEmpty, hasReachedLimits: hasReachedLimits) if strongSelf.currentHistoryState != historyState { strongSelf.currentHistoryState = historyState strongSelf.historyState.set(historyState) @@ -4027,6 +4056,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto if let messageItem = messageItem { let associatedData = messageItem.associatedData + let disableFloatingDateHeaders = messageItem.disableDate loop: for i in 0 ..< historyView.filteredEntries.count { switch historyView.filteredEntries[i] { @@ -4036,7 +4066,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto let item: ListViewItem switch self.mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: self.context, chatLocation: self.chatLocation, associatedData: associatedData, controllerInteraction: self.controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location)) + item = ChatMessageItemImpl(presentationData: presentationData, context: self.context, chatLocation: self.chatLocation, associatedData: associatedData, controllerInteraction: self.controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location), disableDate: disableFloatingDateHeaders) case let .list(_, _, _, displayHeaders, hintLinks, isGlobalSearch): let displayHeader: Bool switch displayHeaders { @@ -4083,6 +4113,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto if let messageItem = messageItem { let associatedData = messageItem.associatedData + let disableFloatingDateHeaders = messageItem.disableDate loop: for i in 0 ..< historyView.filteredEntries.count { switch historyView.filteredEntries[i] { @@ -4092,7 +4123,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto let item: ListViewItem switch self.mode { case .bubbles: - item = ChatMessageItemImpl(presentationData: presentationData, context: self.context, chatLocation: self.chatLocation, associatedData: associatedData, controllerInteraction: self.controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location)) + item = ChatMessageItemImpl(presentationData: presentationData, context: self.context, chatLocation: self.chatLocation, associatedData: associatedData, controllerInteraction: self.controllerInteraction, content: .message(message: message, read: read, selection: selection, attributes: attributes, location: location), disableDate: disableFloatingDateHeaders) case let .list(_, _, _, displayHeaders, hintLinks, isGlobalSearch): let displayHeader: Bool switch displayHeaders { diff --git a/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift b/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift index 47c47ed6c3..47f937eb45 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift @@ -333,7 +333,7 @@ private func extractAdditionalData(view: MessageHistoryView, chatLocation: ChatL readStateData[peerId] = ChatHistoryCombinedInitialReadStateData(unreadCount: readState.count, totalState: totalUnreadState, notificationSettings: notificationSettings) } } - case .replyThread, .feed: + case .replyThread, .customChatContents: break } default: diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index ef1547d4d4..0d41d323aa 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -337,7 +337,7 @@ func canReplyInChat(_ chatPresentationInterfaceState: ChatPresentationInterfaceS } case .replyThread: canReply = true - case .feed: + case .customChatContents: canReply = false } return canReply @@ -597,6 +597,68 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return .single(ContextController.Items(content: .list(actions))) } + if let message = messages.first, case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { + var actions: [ContextMenuItem] = [] + + switch customChatContents.kind { + case .greetingMessageInput, .awayMessageInput, .quickReplyMessageInput: + actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuCopy, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + var messageEntities: [MessageTextEntity]? + var restrictedText: String? + for attribute in message.attributes { + if let attribute = attribute as? TextEntitiesMessageAttribute { + messageEntities = attribute.entities + } + if let attribute = attribute as? RestrictedContentMessageAttribute { + restrictedText = attribute.platformText(platform: "ios", contentSettings: context.currentContentSettings.with { $0 }) ?? "" + } + } + + if let restrictedText = restrictedText { + storeMessageTextInPasteboard(restrictedText, entities: nil) + } else { + if let translationState = chatPresentationInterfaceState.translationState, translationState.isEnabled, + let translation = message.attributes.first(where: { ($0 as? TranslationMessageAttribute)?.toLang == translationState.toLang }) as? TranslationMessageAttribute, !translation.text.isEmpty { + storeMessageTextInPasteboard(translation.text, entities: translation.entities) + } else { + storeMessageTextInPasteboard(message.text, entities: messageEntities) + } + } + + Queue.mainQueue().after(0.2, { + let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied) + controllerInteraction.displayUndo(content) + }) + + f(.default) + }))) + + actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) + }, action: { c, f in + interfaceInteraction.setupEditMessage(messages[0].id, { transition in + f(.custom(transition)) + }) + }))) + + actions.append(.separator) + actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuDelete, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak customChatContents] _, f in + f(.dismissWithoutContent) + + guard let customChatContents else { + return + } + customChatContents.deleteMessages(ids: messages.map(\.id)) + }))) + } + + return .single(ContextController.Items(content: .list(actions))) + } + var loadStickerSaveStatus: MediaId? var loadCopyMediaResource: MediaResource? var isAction = false @@ -654,7 +716,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState canPin = false } else if messages[0].flags.intersection([.Failed, .Unsent]).isEmpty { switch chatPresentationInterfaceState.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: if let channel = messages[0].peers[messages[0].id.peerId] as? TelegramChannel { if !isAction { canPin = channel.hasPermission(.pinMessages) diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift index 6b34ca19c5..c9ad7c3b0a 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift @@ -368,7 +368,7 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState if let user = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil { displayBotStartPanel = true } - } else if let chatHistoryState = chatPresentationInterfaceState.chatHistoryState, case .loaded(true) = chatHistoryState { + } else if let chatHistoryState = chatPresentationInterfaceState.chatHistoryState, case .loaded(true, _) = chatHistoryState { if let user = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil { displayBotStartPanel = true } @@ -401,6 +401,24 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState } } + if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { + switch customChatContents.kind { + case .greetingMessageInput, .awayMessageInput, .quickReplyMessageInput: + displayInputTextPanel = true + } + + if let chatHistoryState = chatPresentationInterfaceState.chatHistoryState, case .loaded(_, true) = chatHistoryState { + if let currentPanel = (currentPanel as? ChatRestrictedInputPanelNode) ?? (currentSecondaryPanel as? ChatRestrictedInputPanelNode) { + return (currentPanel, nil) + } else { + let panel = ChatRestrictedInputPanelNode() + panel.context = context + panel.interfaceInteraction = interfaceInteraction + return (panel, nil) + } + } + } + if case .inline = chatPresentationInterfaceState.mode { displayInputTextPanel = false } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift index a003c03e13..4d546af84f 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift @@ -54,6 +54,15 @@ func leftNavigationButtonForChatInterfaceState(_ presentationInterfaceState: Cha } } } + + if case .customChatContents = presentationInterfaceState.subject { + if case .spacer = currentButton?.action { + return currentButton + } else { + return ChatNavigationButton(action: .spacer, buttonItem: UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil)) + } + } + return nil } @@ -95,7 +104,7 @@ func rightNavigationButtonForChatInterfaceState(context: AccountContext, present var hasMessages = false if let chatHistoryState = presentationInterfaceState.chatHistoryState { - if case .loaded(false) = chatHistoryState { + if case .loaded(false, _) = chatHistoryState { hasMessages = true } } @@ -108,6 +117,16 @@ func rightNavigationButtonForChatInterfaceState(context: AccountContext, present return nil } + if case .customChatContents = presentationInterfaceState.subject { + if let currentButton = currentButton, currentButton.action == .dismiss { + return currentButton + } else { + let buttonItem = UIBarButtonItem(title: strings.Common_Done, style: .done, target: target, action: selector) + buttonItem.accessibilityLabel = strings.Common_Done + return ChatNavigationButton(action: .dismiss, buttonItem: buttonItem) + } + } + if case .replyThread = presentationInterfaceState.chatLocation { if let channel = presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.flags.contains(.isForum) { } else if hasMessages { diff --git a/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift b/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift index c14268f35a..0bce425e9b 100644 --- a/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift +++ b/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift @@ -34,6 +34,7 @@ final class ChatMessageContextExtractedContentSource: ContextExtractedContentSou let blurBackground: Bool = true let centerVertically: Bool + private weak var chatController: ChatControllerImpl? private weak var chatNode: ChatControllerNode? private let engine: TelegramEngine private let message: Message @@ -43,6 +44,9 @@ final class ChatMessageContextExtractedContentSource: ContextExtractedContentSou if self.message.adAttribute != nil { return .single(false) } + if let chatController = self.chatController, case .customChatContents = chatController.subject { + return .single(false) + } return self.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: self.message.id)) |> map { message -> Bool in @@ -55,7 +59,8 @@ final class ChatMessageContextExtractedContentSource: ContextExtractedContentSou |> distinctUntilChanged } - init(chatNode: ChatControllerNode, engine: TelegramEngine, message: Message, selectAll: Bool, centerVertically: Bool = false) { + init(chatController: ChatControllerImpl, chatNode: ChatControllerNode, engine: TelegramEngine, message: Message, selectAll: Bool, centerVertically: Bool = false) { + self.chatController = chatController self.chatNode = chatNode self.engine = engine self.message = message diff --git a/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift index 2325486c84..96bc589143 100644 --- a/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift @@ -101,6 +101,14 @@ final class ChatRestrictedInputPanelNode: ChatInputPanelNode { self.textNode.attributedText = NSAttributedString(string: interfaceState.strings.Conversation_DefaultRestrictedText, font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor) } } + } else if case let .customChatContents(customChatContents) = interfaceState.subject { + let displayCount: Int + switch customChatContents.kind { + case .greetingMessageInput, .awayMessageInput, .quickReplyMessageInput: + displayCount = 20 + } + //TODO:localize + self.textNode.attributedText = NSAttributedString(string: "Limit of \(displayCount) messages reached", font: Font.regular(13.0), textColor: interfaceState.theme.chat.inputPanel.secondaryTextColor) } self.buttonNode.isUserInteractionEnabled = isUserInteractionEnabled diff --git a/submodules/TelegramUI/Sources/ChatSearchNavigationContentNode.swift b/submodules/TelegramUI/Sources/ChatSearchNavigationContentNode.swift index 8ad90ba599..7342396521 100644 --- a/submodules/TelegramUI/Sources/ChatSearchNavigationContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchNavigationContentNode.swift @@ -34,7 +34,7 @@ final class ChatSearchNavigationContentNode: NavigationBarContentNode { self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasBackground: false, hasSeparator: false), strings: strings, fieldStyle: .modern) let placeholderText: String switch chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: if chatLocation.peerId == context.account.peerId, presentationInterfaceState.hasSearchTags { if case .standard(.embedded(false)) = presentationInterfaceState.mode { placeholderText = strings.Common_Search @@ -114,7 +114,7 @@ final class ChatSearchNavigationContentNode: NavigationBarContentNode { self.searchBar.prefixString = nil let placeholderText: String switch self.chatLocation { - case .peer, .replyThread, .feed: + case .peer, .replyThread, .customChatContents: if presentationInterfaceState.historyFilter != nil { placeholderText = self.strings.Common_Search } else if self.chatLocation.peerId == self.context.account.peerId, presentationInterfaceState.hasSearchTags { diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index ffd5ac831b..ae3903849f 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -1517,7 +1517,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch } else { if let user = interfaceState.renderedPeer?.peer as? TelegramUser, user.botInfo != nil { - if let chatHistoryState = interfaceState.chatHistoryState, case .loaded(true) = chatHistoryState { + if let chatHistoryState = interfaceState.chatHistoryState, case .loaded(true, _) = chatHistoryState { displayBotStartButton = true } else if interfaceState.peerIsBlocked { displayBotStartButton = true @@ -1801,38 +1801,56 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch let dismissedButtonMessageUpdated = interfaceState.interfaceState.messageActionsState.dismissedButtonKeyboardMessageId != previousState?.interfaceState.messageActionsState.dismissedButtonKeyboardMessageId let replyMessageUpdated = interfaceState.interfaceState.replyMessageSubject != previousState?.interfaceState.replyMessageSubject - if let peer = interfaceState.renderedPeer?.peer, previousState?.renderedPeer?.peer == nil || !peer.isEqual(previousState!.renderedPeer!.peer!) || previousState?.interfaceState.silentPosting != interfaceState.interfaceState.silentPosting || themeUpdated || !self.initializedPlaceholder || previousState?.keyboardButtonsMessage?.id != interfaceState.keyboardButtonsMessage?.id || previousState?.keyboardButtonsMessage?.visibleReplyMarkupPlaceholder != interfaceState.keyboardButtonsMessage?.visibleReplyMarkupPlaceholder || dismissedButtonMessageUpdated || replyMessageUpdated || (previousState?.interfaceState.editMessage == nil) != (interfaceState.interfaceState.editMessage == nil) || previousState?.forumTopicData != interfaceState.forumTopicData || previousState?.replyMessage?.id != interfaceState.replyMessage?.id { + var peerUpdated = false + if let peer = interfaceState.renderedPeer?.peer, previousState?.renderedPeer?.peer == nil || !peer.isEqual(previousState!.renderedPeer!.peer!) { + peerUpdated = true + } + + if peerUpdated || previousState?.interfaceState.silentPosting != interfaceState.interfaceState.silentPosting || themeUpdated || !self.initializedPlaceholder || previousState?.keyboardButtonsMessage?.id != interfaceState.keyboardButtonsMessage?.id || previousState?.keyboardButtonsMessage?.visibleReplyMarkupPlaceholder != interfaceState.keyboardButtonsMessage?.visibleReplyMarkupPlaceholder || dismissedButtonMessageUpdated || replyMessageUpdated || (previousState?.interfaceState.editMessage == nil) != (interfaceState.interfaceState.editMessage == nil) || previousState?.forumTopicData != interfaceState.forumTopicData || previousState?.replyMessage?.id != interfaceState.replyMessage?.id { self.initializedPlaceholder = true - var placeholder: String + var placeholder: String = "" - if let channel = peer as? TelegramChannel, case .broadcast = channel.info { - if interfaceState.interfaceState.silentPosting { - placeholder = interfaceState.strings.Conversation_InputTextSilentBroadcastPlaceholder - } else { - placeholder = interfaceState.strings.Conversation_InputTextBroadcastPlaceholder - } - } else { - if sendingTextDisabled { - placeholder = interfaceState.strings.Chat_PlaceholderTextNotAllowed - } else { - if let channel = peer as? TelegramChannel, case .group = channel.info, channel.hasPermission(.canBeAnonymous) { - placeholder = interfaceState.strings.Conversation_InputTextAnonymousPlaceholder - } else if case let .replyThread(replyThreadMessage) = interfaceState.chatLocation, !replyThreadMessage.isForumPost, replyThreadMessage.peerId != self.context?.account.peerId { - if replyThreadMessage.isChannelPost { - placeholder = interfaceState.strings.Conversation_InputTextPlaceholderComment - } else { - placeholder = interfaceState.strings.Conversation_InputTextPlaceholderReply - } - } else if let channel = peer as? TelegramChannel, channel.isForum, let forumTopicData = interfaceState.forumTopicData { - if let replyMessage = interfaceState.replyMessage, let threadInfo = replyMessage.associatedThreadInfo { - placeholder = interfaceState.strings.Chat_InputPlaceholderReplyInTopic(threadInfo.title).string - } else { - placeholder = interfaceState.strings.Chat_InputPlaceholderMessageInTopic(forumTopicData.title).string - } + if let peer = interfaceState.renderedPeer?.peer { + if let channel = peer as? TelegramChannel, case .broadcast = channel.info { + if interfaceState.interfaceState.silentPosting { + placeholder = interfaceState.strings.Conversation_InputTextSilentBroadcastPlaceholder } else { - placeholder = interfaceState.strings.Conversation_InputTextPlaceholder + placeholder = interfaceState.strings.Conversation_InputTextBroadcastPlaceholder } + } else { + if sendingTextDisabled { + placeholder = interfaceState.strings.Chat_PlaceholderTextNotAllowed + } else { + if let channel = peer as? TelegramChannel, case .group = channel.info, channel.hasPermission(.canBeAnonymous) { + placeholder = interfaceState.strings.Conversation_InputTextAnonymousPlaceholder + } else if case let .replyThread(replyThreadMessage) = interfaceState.chatLocation, !replyThreadMessage.isForumPost, replyThreadMessage.peerId != self.context?.account.peerId { + if replyThreadMessage.isChannelPost { + placeholder = interfaceState.strings.Conversation_InputTextPlaceholderComment + } else { + placeholder = interfaceState.strings.Conversation_InputTextPlaceholderReply + } + } else if let channel = peer as? TelegramChannel, channel.isForum, let forumTopicData = interfaceState.forumTopicData { + if let replyMessage = interfaceState.replyMessage, let threadInfo = replyMessage.associatedThreadInfo { + placeholder = interfaceState.strings.Chat_InputPlaceholderReplyInTopic(threadInfo.title).string + } else { + placeholder = interfaceState.strings.Chat_InputPlaceholderMessageInTopic(forumTopicData.title).string + } + } else { + placeholder = interfaceState.strings.Conversation_InputTextPlaceholder + } + } + } + } + if case let .customChatContents(customChatContents) = interfaceState.subject { + //TODO:localize + switch customChatContents.kind { + case .greetingMessageInput: + placeholder = "Add greeting message..." + case .awayMessageInput: + placeholder = "Add away message..." + case .quickReplyMessageInput: + placeholder = "Add quick reply..." } } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index b9f9ed2cfb..d9cb825dd6 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -1899,8 +1899,12 @@ public final class SharedAccountContextImpl: SharedAccountContext { return BusinessHoursSetupScreen(context: context) } - public func makeGreetingMessageSetupScreen(context: AccountContext) -> ViewController { - return GreetingMessageSetupScreen(context: context) + public func makeGreetingMessageSetupScreen(context: AccountContext, isAwayMode: Bool) -> ViewController { + return GreetingMessageSetupScreen(context: context, mode: isAwayMode ? .away : .greeting) + } + + public func makeQuickReplySetupScreen(context: AccountContext) -> ViewController { + return QuickReplySetupScreen(context: context) } public func makePremiumIntroController(context: AccountContext, source: PremiumIntroSource, forceDark: Bool, dismissed: (() -> Void)?) -> ViewController {