[WIP] Business

This commit is contained in:
Isaac
2024-02-27 16:20:37 +04:00
parent 729a260626
commit aa4ca00cb0
51 changed files with 2521 additions and 362 deletions

View File

@@ -183,11 +183,7 @@ final class AutomaticBusinessMessageSetupChatContents: ChatCustomContentsProtoco
let initialShortcut: String
switch kind {
case .awayMessageInput:
initialShortcut = "_away"
case .greetingMessageInput:
initialShortcut = "_greeting"
case let .quickReplyMessageInput(shortcut):
case let .quickReplyMessageInput(shortcut, _):
initialShortcut = shortcut
}
@@ -217,9 +213,12 @@ final class AutomaticBusinessMessageSetupChatContents: ChatCustomContentsProtoco
}
func quickReplyUpdateShortcut(value: String) {
self.kind = .quickReplyMessageInput(shortcut: value)
self.impl.with { impl in
impl.quickReplyUpdateShortcut(value: value)
switch self.kind {
case let .quickReplyMessageInput(_, shortcutType):
self.kind = .quickReplyMessageInput(shortcut: value, shortcutType: shortcutType)
self.impl.with { impl in
impl.quickReplyUpdateShortcut(value: value)
}
}
}
}

View File

@@ -76,7 +76,7 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
}
}
private struct AdditionalPeerList {
struct AdditionalPeerList {
enum Category: Int {
case newChats = 0
case existingChats = 1
@@ -148,6 +148,8 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
private var replyToMessages: Bool = true
private var inactivityDays: Int = 7
override init(frame: CGRect) {
self.scrollView = ScrollView()
self.scrollView.showsVerticalScrollIndicator = true
@@ -182,6 +184,66 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
}
func attemptNavigation(complete: @escaping () -> Void) -> Bool {
guard let component = self.component else {
return true
}
var mappedCategories: TelegramBusinessRecipients.Categories = []
if self.additionalPeerList.categories.contains(.existingChats) {
mappedCategories.insert(.existingChats)
}
if self.additionalPeerList.categories.contains(.newChats) {
mappedCategories.insert(.newChats)
}
if self.additionalPeerList.categories.contains(.contacts) {
mappedCategories.insert(.contacts)
}
if self.additionalPeerList.categories.contains(.nonContacts) {
mappedCategories.insert(.nonContacts)
}
let recipients = TelegramBusinessRecipients(
categories: mappedCategories,
additionalPeers: Set(self.additionalPeerList.peers.map(\.peer.id)),
exclude: self.hasAccessToAllChatsByDefault
)
switch component.mode {
case .greeting:
var greetingMessage: TelegramBusinessGreetingMessage?
if self.isOn, let currentShortcut = self.currentShortcut {
greetingMessage = TelegramBusinessGreetingMessage(
shortcutId: currentShortcut.id,
recipients: recipients,
inactivityDays: self.inactivityDays
)
}
let _ = component.context.engine.accountData.updateBusinessGreetingMessage(greetingMessage: greetingMessage).startStandalone()
case .away:
var awayMessage: TelegramBusinessAwayMessage?
if self.isOn, let currentShortcut = self.currentShortcut {
let mappedSchedule: TelegramBusinessAwayMessage.Schedule
switch self.schedule {
case .always:
mappedSchedule = .always
case .outsideBusinessHours:
mappedSchedule = .outsideWorkingHours
case .custom:
if let customScheduleStart = self.customScheduleStart, let customScheduleEnd = self.customScheduleEnd {
mappedSchedule = .custom(beginTimestamp: Int32(customScheduleStart.timeIntervalSince1970), endTimestamp: Int32(customScheduleEnd.timeIntervalSince1970))
} else {
//TODO:localize
return false
}
}
awayMessage = TelegramBusinessAwayMessage(
shortcutId: currentShortcut.id,
recipients: recipients,
schedule: mappedSchedule
)
}
let _ = component.context.engine.accountData.updateBusinessAwayMessage(awayMessage: awayMessage).startStandalone()
}
return true
}
@@ -236,14 +298,14 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
let additionalCategories: [ChatListNodeAdditionalCategory] = [
ChatListNodeAdditionalCategory(
id: self.hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .purple),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple),
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), cornerRadius: 12.0, color: .purple),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: self.hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple),
title: self.hasAccessToAllChatsByDefault ? "Existing Chats" : "New Chats"
),
ChatListNodeAdditionalCategory(
id: AdditionalCategoryId.contacts.rawValue,
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .blue),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue),
icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .blue),
smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue),
title: "Contacts"
),
ChatListNodeAdditionalCategory(
@@ -354,16 +416,19 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
}
let shortcutName: String
let shortcutType: ChatQuickReplyShortcutType
switch component.mode {
case .greeting:
shortcutName = "hello"
shortcutType = .greeting
case .away:
shortcutName = "away"
shortcutType = .away
}
let contents = AutomaticBusinessMessageSetupChatContents(
context: component.context,
kind: .quickReplyMessageInput(shortcut: shortcutName),
kind: .quickReplyMessageInput(shortcut: shortcutName, shortcutType: shortcutType),
shortcutId: self.currentShortcut?.id
)
let chatController = component.context.sharedContext.makeChatController(
@@ -471,13 +536,58 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
if self.component == nil {
self.accountPeer = component.initialData.accountPeer
var initialRecipients: TelegramBusinessRecipients?
let shortcutName: String
switch component.mode {
case .greeting:
shortcutName = "hello"
if let greetingMessage = component.initialData.greetingMessage {
self.isOn = true
initialRecipients = greetingMessage.recipients
self.inactivityDays = greetingMessage.inactivityDays
}
case .away:
shortcutName = "away"
if let awayMessage = component.initialData.awayMessage {
self.isOn = true
initialRecipients = awayMessage.recipients
}
}
if let initialRecipients {
var mappedCategories = Set<AdditionalPeerList.Category>()
if initialRecipients.categories.contains(.existingChats) {
mappedCategories.insert(.existingChats)
}
if initialRecipients.categories.contains(.newChats) {
mappedCategories.insert(.newChats)
}
if initialRecipients.categories.contains(.contacts) {
mappedCategories.insert(.contacts)
}
if initialRecipients.categories.contains(.nonContacts) {
mappedCategories.insert(.nonContacts)
}
var additionalPeers: [AdditionalPeerList.Peer] = []
for peerId in initialRecipients.additionalPeers {
if let peer = component.initialData.additionalPeers[peerId] {
additionalPeers.append(peer)
}
}
self.additionalPeerList = AdditionalPeerList(
categories: mappedCategories,
peers: additionalPeers
)
self.hasAccessToAllChatsByDefault = initialRecipients.exclude
}
self.currentShortcut = component.initialData.shortcutMessageList.items.first(where: { $0.shortcut == shortcutName })
self.currentShortcutDisposable = (component.context.engine.accountData.shortcutMessageList()
@@ -532,9 +642,6 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let sectionSpacing: CGFloat = 32.0
let _ = bottomContentInset
let _ = sectionSpacing
var contentHeight: CGFloat = 0.0
contentHeight += environment.navigationHeight
@@ -548,7 +655,7 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 2.0), size: iconSize)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 8.0), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.scrollView.addSubview(iconView)
@@ -557,7 +664,7 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
}
contentHeight += 129.0
contentHeight += 124.0
//TODO:localize
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(
@@ -1163,6 +1270,19 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
otherSectionsHeight += sectionSpacing
if case .greeting = component.mode {
var selectedInactivityIndex = 0
let valueList: [Int] = [
7,
14,
21,
28
]
for i in 0 ..< valueList.count {
if valueList[i] <= self.inactivityDays {
selectedInactivityIndex = i
}
}
let periodSectionSize = self.periodSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
@@ -1192,12 +1312,14 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
"21 days",
"28 days"
],
selectedIndex: 0,
selectedIndex: selectedInactivityIndex,
selectedIndexUpdated: { [weak self] index in
guard let self else {
return
}
let _ = self
let index = max(0, min(valueList.count - 1, index))
self.inactivityDays = valueList[index]
self.state?.updated(transition: .immediate)
}
)))
]
@@ -1268,15 +1390,24 @@ final class AutomaticBusinessMessageSetupScreenComponent: Component {
public final class AutomaticBusinessMessageSetupScreen: ViewControllerComponentContainer {
public final class InitialData: AutomaticBusinessMessageSetupScreenInitialData {
let accountPeer: EnginePeer?
let shortcutMessageList: ShortcutMessageList
fileprivate let accountPeer: EnginePeer?
fileprivate let shortcutMessageList: ShortcutMessageList
fileprivate let greetingMessage: TelegramBusinessGreetingMessage?
fileprivate let awayMessage: TelegramBusinessAwayMessage?
fileprivate let additionalPeers: [EnginePeer.Id: AutomaticBusinessMessageSetupScreenComponent.AdditionalPeerList.Peer]
init(
fileprivate init(
accountPeer: EnginePeer?,
shortcutMessageList: ShortcutMessageList
shortcutMessageList: ShortcutMessageList,
greetingMessage: TelegramBusinessGreetingMessage?,
awayMessage: TelegramBusinessAwayMessage?,
additionalPeers: [EnginePeer.Id: AutomaticBusinessMessageSetupScreenComponent.AdditionalPeerList.Peer]
) {
self.accountPeer = accountPeer
self.shortcutMessageList = shortcutMessageList
self.greetingMessage = greetingMessage
self.awayMessage = awayMessage
self.additionalPeers = additionalPeers
}
}
@@ -1334,16 +1465,48 @@ public final class AutomaticBusinessMessageSetupScreen: ViewControllerComponentC
public static func initialData(context: AccountContext) -> Signal<AutomaticBusinessMessageSetupScreenInitialData, NoError> {
return combineLatest(
context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)
TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId),
TelegramEngine.EngineData.Item.Peer.BusinessGreetingMessage(id: context.account.peerId),
TelegramEngine.EngineData.Item.Peer.BusinessAwayMessage(id: context.account.peerId)
),
context.engine.accountData.shortcutMessageList()
|> take(1)
)
|> map { accountPeer, shortcutMessageList -> AutomaticBusinessMessageSetupScreenInitialData in
return InitialData(
accountPeer: accountPeer,
shortcutMessageList: shortcutMessageList
|> mapToSignal { data, shortcutMessageList -> Signal<AutomaticBusinessMessageSetupScreenInitialData, NoError> in
let (accountPeer, greetingMessage, awayMessage) = data
var additionalPeerIds = Set<EnginePeer.Id>()
if let greetingMessage {
additionalPeerIds.formUnion(greetingMessage.recipients.additionalPeers)
}
if let awayMessage {
additionalPeerIds.formUnion(awayMessage.recipients.additionalPeers)
}
return context.engine.data.get(
EngineDataMap(additionalPeerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))),
EngineDataMap(additionalPeerIds.map(TelegramEngine.EngineData.Item.Peer.IsContact.init(id:)))
)
|> map { peers, isContacts -> AutomaticBusinessMessageSetupScreenInitialData in
var additionalPeers: [EnginePeer.Id: AutomaticBusinessMessageSetupScreenComponent.AdditionalPeerList.Peer] = [:]
for id in additionalPeerIds {
guard let peer = peers[id], let peer else {
continue
}
additionalPeers[id] = AutomaticBusinessMessageSetupScreenComponent.AdditionalPeerList.Peer(
peer: peer,
isContact: isContacts[id] ?? false
)
}
return InitialData(
accountPeer: accountPeer,
shortcutMessageList: shortcutMessageList,
greetingMessage: greetingMessage,
awayMessage: awayMessage,
additionalPeers: additionalPeers
)
}
}
}
}

View File

@@ -22,6 +22,7 @@ import ChatListHeaderComponent
import PlainButtonComponent
import MultilineTextComponent
import AttachmentUI
import SearchBarNode
final class QuickReplySetupScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
@@ -453,6 +454,8 @@ final class QuickReplySetupScreenComponent: Component {
var options: ListViewDeleteAndInsertOptions = [.Synchronous, .LowLatency]
if animated {
options.insert(.AnimateInsertion)
} else {
options.insert(.PreferSynchronousResourceLoading)
}
self.transaction(
@@ -476,6 +479,8 @@ final class QuickReplySetupScreenComponent: Component {
private let navigationBarView = ComponentView<Empty>()
private var navigationHeight: CGFloat?
private var searchBarNode: SearchBarNode?
private var selectionPanel: ComponentView<Empty>?
private var isUpdating: Bool = false
@@ -492,10 +497,14 @@ final class QuickReplySetupScreenComponent: Component {
private var isEditing: Bool = false
private var isSearchDisplayControllerActive: Bool = false
private var searchQuery: String = ""
private let searchQueryComponentSeparationCharacterSet: CharacterSet
private var accountPeer: EnginePeer?
override init(frame: CGRect) {
self.searchQueryComponentSeparationCharacterSet = CharacterSet(charactersIn: " _.:/")
super.init(frame: frame)
}
@@ -528,9 +537,18 @@ final class QuickReplySetupScreenComponent: Component {
}
if let shortcut {
let shortcutType: ChatQuickReplyShortcutType
if shortcut == "hello" {
shortcutType = .greeting
} else if shortcut == "away" {
shortcutType = .away
} else {
shortcutType = .generic
}
let contents = AutomaticBusinessMessageSetupChatContents(
context: component.context,
kind: .quickReplyMessageInput(shortcut: shortcut),
kind: .quickReplyMessageInput(shortcut: shortcut, shortcutType: shortcutType),
shortcutId: shortcutId
)
let chatController = component.context.sharedContext.makeChatController(
@@ -779,9 +797,8 @@ final class QuickReplySetupScreenComponent: Component {
return
}
let _ = self
//self.isSearchDisplayControllerActive = true
//self.state?.updated(transition: .spring(duration: 0.4))
self.isSearchDisplayControllerActive = true
self.state?.updated(transition: .spring(duration: 0.4))
},
openStatusSetup: { _ in
},
@@ -952,6 +969,84 @@ final class QuickReplySetupScreenComponent: Component {
)
self.navigationHeight = navigationHeight
var removedSearchBar: SearchBarNode?
if self.isSearchDisplayControllerActive {
let searchBarNode: SearchBarNode
var searchBarTransition = transition
if let current = self.searchBarNode {
searchBarNode = current
} else {
searchBarTransition = .immediate
let searchBarTheme = SearchBarNodeTheme(theme: environment.theme, hasSeparator: false)
searchBarNode = SearchBarNode(
theme: searchBarTheme,
strings: environment.strings,
fieldStyle: .modern,
displayBackground: false
)
searchBarNode.placeholderString = NSAttributedString(string: environment.strings.Common_Search, font: Font.regular(17.0), textColor: searchBarTheme.placeholder)
self.searchBarNode = searchBarNode
searchBarNode.cancel = { [weak self] in
guard let self else {
return
}
self.isSearchDisplayControllerActive = false
self.state?.updated(transition: .spring(duration: 0.4))
}
searchBarNode.textUpdated = { [weak self] query, _ in
guard let self else {
return
}
if self.searchQuery != query {
self.searchQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
self.state?.updated(transition: .immediate)
}
}
DispatchQueue.main.async { [weak self, weak searchBarNode] in
guard let self, let searchBarNode, self.searchBarNode === searchBarNode else {
return
}
searchBarNode.activate()
if let controller = self.environment?.controller() as? QuickReplySetupScreen {
controller.requestAttachmentMenuExpansion()
}
}
}
var searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight - 54.0 + 2.0), size: CGSize(width: availableSize.width, height: 54.0))
if isModal {
searchBarFrame.origin.y += 2.0
}
searchBarNode.updateLayout(boundingSize: searchBarFrame.size, leftInset: environment.safeInsets.left + 6.0, rightInset: environment.safeInsets.right, transition: searchBarTransition.containedViewLayoutTransition)
searchBarTransition.setFrame(view: searchBarNode.view, frame: searchBarFrame)
if searchBarNode.view.superview == nil {
self.addSubview(searchBarNode.view)
if case let .curve(duration, curve) = transition.animation, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode = navigationBarView.searchContentNode?.placeholderNode {
let timingFunction: String
switch curve {
case .easeInOut:
timingFunction = CAMediaTimingFunctionName.easeOut.rawValue
case .linear:
timingFunction = CAMediaTimingFunctionName.linear.rawValue
case .spring:
timingFunction = kCAMediaTimingFunctionSpring
case .custom:
timingFunction = kCAMediaTimingFunctionSpring
}
searchBarNode.animateIn(from: placeholderNode, duration: duration, timingFunction: timingFunction)
}
}
} else {
self.searchQuery = ""
if let searchBarNode = self.searchBarNode {
self.searchBarNode = nil
removedSearchBar = searchBarNode
}
}
if !self.selectedIds.isEmpty {
let selectionPanel: ComponentView<Empty>
var selectionPanelTransition = transition
@@ -1058,11 +1153,25 @@ final class QuickReplySetupScreenComponent: Component {
if let shortcutMessageList = self.shortcutMessageList, let accountPeer = self.accountPeer {
switch component.mode {
case .manage:
entries.append(.add)
if self.searchQuery.isEmpty {
entries.append(.add)
}
case .select:
break
}
for item in shortcutMessageList.items {
if !self.searchQuery.isEmpty {
var matches = false
inner: for nameComponent in item.shortcut.lowercased().components(separatedBy: self.searchQueryComponentSeparationCharacterSet) {
if nameComponent.lowercased().hasPrefix(self.searchQuery) {
matches = true
break inner
}
}
if !matches {
continue
}
}
entries.append(.item(item: item, accountPeer: accountPeer, sortIndex: entries.count, isEditing: self.isEditing, isSelected: self.selectedIds.contains(item.id)))
}
}
@@ -1081,6 +1190,17 @@ final class QuickReplySetupScreenComponent: Component {
navigationBarComponentView.applyCurrentScroll(transition: transition)
}
if let removedSearchBar {
if !transition.animation.isImmediate, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode =
navigationBarView.searchContentNode?.placeholderNode {
removedSearchBar.transitionOut(to: placeholderNode, transition: transition.containedViewLayoutTransition, completion: { [weak removedSearchBar] in
removedSearchBar?.view.removeFromSuperview()
})
} else {
removedSearchBar.view.removeFromSuperview()
}
}
return availableSize
}
}