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 0000000000..b766d6e438 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/ZzzEmoji.tgs differ 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 {