diff --git a/GliaWidgets.xcodeproj/project.pbxproj b/GliaWidgets.xcodeproj/project.pbxproj index ed9970171..44e603e71 100644 --- a/GliaWidgets.xcodeproj/project.pbxproj +++ b/GliaWidgets.xcodeproj/project.pbxproj @@ -353,6 +353,12 @@ 84681A8E2A5ED76300DD7406 /* ChatViewModel+GVA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A8D2A5ED76300DD7406 /* ChatViewModel+GVA.swift */; }; 84681A952A61844000DD7406 /* ChatViewModelTests+Gva.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A942A61844000DD7406 /* ChatViewModelTests+Gva.swift */; }; 84681A982A61853300DD7406 /* GvaOption.Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A972A61853300DD7406 /* GvaOption.Mock.swift */; }; + 84681A9B2A669D8800DD7406 /* QuickReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A9A2A669D8800DD7406 /* QuickReplyView.swift */; }; + 84681A9D2A669DB500DD7406 /* QuickReplyButtonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A9C2A669DB500DD7406 /* QuickReplyButtonCell.swift */; }; + 84681A9F2A66B70400DD7406 /* LeftAlignedCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681A9E2A66B70400DD7406 /* LeftAlignedCollectionViewFlowLayout.swift */; }; + 84681AA12A66B9F100DD7406 /* SelfSizingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681AA02A66B9F100DD7406 /* SelfSizingCollectionView.swift */; }; + 84681AA32A66D90000DD7406 /* AdjustedTouchAreaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681AA22A66D90000DD7406 /* AdjustedTouchAreaButton.swift */; }; + 84681AA72A681EF900DD7406 /* QuickReplyButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84681AA62A681EF900DD7406 /* QuickReplyButtonStyle.swift */; }; 846A5C3429CB3A130049B29F /* ScreenShareHandler.Implementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846A5C3329CB3A130049B29F /* ScreenShareHandler.Implementation.swift */; }; 846A5C3629CB3E270049B29F /* ScreenShareHandler.Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846A5C3529CB3E270049B29F /* ScreenShareHandler.Mock.swift */; }; 846A5C3929D18D400049B29F /* ScreenShareHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846A5C3829D18D400049B29F /* ScreenShareHandlerTests.swift */; }; @@ -1020,6 +1026,12 @@ 84681A8D2A5ED76300DD7406 /* ChatViewModel+GVA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatViewModel+GVA.swift"; sourceTree = ""; }; 84681A942A61844000DD7406 /* ChatViewModelTests+Gva.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatViewModelTests+Gva.swift"; sourceTree = ""; }; 84681A972A61853300DD7406 /* GvaOption.Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GvaOption.Mock.swift; sourceTree = ""; }; + 84681A9A2A669D8800DD7406 /* QuickReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickReplyView.swift; sourceTree = ""; }; + 84681A9C2A669DB500DD7406 /* QuickReplyButtonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickReplyButtonCell.swift; sourceTree = ""; }; + 84681A9E2A66B70400DD7406 /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; + 84681AA02A66B9F100DD7406 /* SelfSizingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfSizingCollectionView.swift; sourceTree = ""; }; + 84681AA22A66D90000DD7406 /* AdjustedTouchAreaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedTouchAreaButton.swift; sourceTree = ""; }; + 84681AA62A681EF900DD7406 /* QuickReplyButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickReplyButtonStyle.swift; sourceTree = ""; }; 846A5C3329CB3A130049B29F /* ScreenShareHandler.Implementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareHandler.Implementation.swift; sourceTree = ""; }; 846A5C3529CB3E270049B29F /* ScreenShareHandler.Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareHandler.Mock.swift; sourceTree = ""; }; 846A5C3829D18D400049B29F /* ScreenShareHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareHandlerTests.swift; sourceTree = ""; }; @@ -2095,6 +2107,7 @@ 1AC7A7B42587A07D00567FF8 /* Action */, 1A60B0132567FC6400E53F53 /* Style */, 1A60B0142567FC7000E53F53 /* Button.swift */, + 84681AA22A66D90000DD7406 /* AdjustedTouchAreaButton.swift */, ); path = Button; sourceTree = ""; @@ -2187,6 +2200,8 @@ 1A63B2F0257A3F0F00508478 /* Common */ = { isa = PBXGroup; children = ( + 84681AA52A681E8400DD7406 /* LeftAlignedCollectionViewFlowLayout */, + 84681AA42A681E6900DD7406 /* SelfSizingCollectionView */, 1AA738B025790D4B00E1120F /* Alert */, ); path = Common; @@ -2999,6 +3014,32 @@ path = Mocks; sourceTree = ""; }; + 84681A992A669D5000DD7406 /* QuickReply */ = { + isa = PBXGroup; + children = ( + 84681A9A2A669D8800DD7406 /* QuickReplyView.swift */, + 84681A9C2A669DB500DD7406 /* QuickReplyButtonCell.swift */, + 84681AA62A681EF900DD7406 /* QuickReplyButtonStyle.swift */, + ); + path = QuickReply; + sourceTree = ""; + }; + 84681AA42A681E6900DD7406 /* SelfSizingCollectionView */ = { + isa = PBXGroup; + children = ( + 84681AA02A66B9F100DD7406 /* SelfSizingCollectionView.swift */, + ); + path = SelfSizingCollectionView; + sourceTree = ""; + }; + 84681AA52A681E8400DD7406 /* LeftAlignedCollectionViewFlowLayout */ = { + isa = PBXGroup; + children = ( + 84681A9E2A66B70400DD7406 /* LeftAlignedCollectionViewFlowLayout.swift */, + ); + path = LeftAlignedCollectionViewFlowLayout; + sourceTree = ""; + }; 846A5C3729D18D220049B29F /* ScreenShareHandler */ = { isa = PBXGroup; children = ( @@ -3199,6 +3240,7 @@ C0175A262A67D431001FACDE /* GVA */ = { isa = PBXGroup; children = ( + 84681A992A669D5000DD7406 /* QuickReply */, C0175A162A5D30D7001FACDE /* GvaResponseTextView.swift */, C0175A222A65614E001FACDE /* GvaPersistentButtonView.swift */, C0175A242A66A431001FACDE /* GvaPersistentButtonOptionView.swift */, @@ -4084,6 +4126,7 @@ 84D5B9642A151B6100807F92 /* QuickLookBased.Mock.swift in Sources */, 311CAFCD29F8FAE20067B59F /* SecureConversations.TranscriptModel+CustomCard.swift in Sources */, 9A19926B27D3BA8700161AAE /* ViewFactory.Environment.Mock.swift in Sources */, + 84681A9B2A669D8800DD7406 /* QuickReplyView.swift in Sources */, 1A6EB05725A717CB0007081A /* ChatMessage.swift in Sources */, 1AA738B225790D5A00E1120F /* AlertView.swift in Sources */, 845E2F8E283FB5B500C04D56 /* Theme.Survey.SingleQuestion.Accessibility.swift in Sources */, @@ -4138,6 +4181,7 @@ C0D2F08229A4E75200803B47 /* Header.Mock.swift in Sources */, 1A4AD3B3256D2A7600468BFB /* VisitorChatMessageStyle.swift in Sources */, 845E2F98283FC9A900C04D56 /* Theme.Survey.OptionButton.swift in Sources */, + 84681A9F2A66B70400DD7406 /* LeftAlignedCollectionViewFlowLayout.swift in Sources */, 84265E60298D7B2900D65842 /* ScreenSharingCoordinator+Environment.swift in Sources */, 9AB196DC27C3FFCC00FD60AB /* Call.Environment.Interface.swift in Sources */, 3197F7AF29E95527008EE9F7 /* SystemMessageView.swift in Sources */, @@ -4182,6 +4226,7 @@ 845A28FC28AFF092008558EA /* URLScheme.swift in Sources */, 75F58EE127E7D5300065BA2D /* Survey.ViewController.Props.swift in Sources */, 754CC61527E27C42005676E9 /* Survey.Checkbox.swift in Sources */, + 84681AA72A681EF900DD7406 /* QuickReplyButtonStyle.swift in Sources */, 1A4AD3CA256E864800468BFB /* ThemeColorStyle.swift in Sources */, AFA2FDF228907E9D00428E6D /* GliaViewController.Mock.swift in Sources */, 9AB196DE27C3FFF400FD60AB /* Call.Environment.Mock.swift in Sources */, @@ -4215,6 +4260,7 @@ 9A19926527D3BA3A00161AAE /* UIKitBased.Mock.swift in Sources */, C0D2F06B29A4DAA000803B47 /* VideoCallViewMock.swift in Sources */, 1AFB1E6225F7AE1300CA460D /* ChatEngagementFile.swift in Sources */, + 84681A9D2A669DB500DD7406 /* QuickReplyButtonCell.swift in Sources */, 3100EEF2293E214B00D57F71 /* SecureConversations.Coordinator.swift in Sources */, 1AA738AE2578E0D500E1120F /* ConnectAnimationView.swift in Sources */, 754CC61627E2816F005676E9 /* Survey.InputQuestionView.swift in Sources */, @@ -4276,6 +4322,7 @@ 755D186B29A6A5830009F5E8 /* WelcomeStyle+MessageTitleStyle.swift in Sources */, 75940981298D38C2008B173A /* VisitorCodeView+NumberView.swift in Sources */, 7529F2B427E1D503004D3581 /* Survey.swift in Sources */, + 84681AA12A66B9F100DD7406 /* SelfSizingCollectionView.swift in Sources */, 1A5F494B25CA86CA003E3678 /* Call.swift in Sources */, 84265E65298D7B2900D65842 /* ScreenSharingView.swift in Sources */, 9A1992ED27D6C19E00161AAE /* FileSystemStorage.Environment.Mock.swift in Sources */, @@ -4366,6 +4413,7 @@ 7594093D298D376B008B173A /* RemoteConfiguration+AssetsBuilder.swift in Sources */, 9A3E1D9B27B73246005634EB /* FoundationBased.Mock.swift in Sources */, 75FF151427F3A2D600FE7BE2 /* Theme+Survey.swift in Sources */, + 84681AA32A66D90000DD7406 /* AdjustedTouchAreaButton.swift in Sources */, 1A60AFE825669C5000E53F53 /* ChatStyle.swift in Sources */, 75940984298D38C2008B173A /* VisitorCodeViewController.swift in Sources */, 1A4AF5C725AEEA42002CD0F4 /* Operator+Extensions.swift in Sources */, diff --git a/GliaWidgets/Sources/Component/Button/AdjustedTouchAreaButton.swift b/GliaWidgets/Sources/Component/Button/AdjustedTouchAreaButton.swift new file mode 100644 index 000000000..134620016 --- /dev/null +++ b/GliaWidgets/Sources/Component/Button/AdjustedTouchAreaButton.swift @@ -0,0 +1,24 @@ +import UIKit + +class AdjustedTouchAreaButton: UIButton { + private var touchAreaInsets: TouchAreaInsets? + + init(touchAreaInsets: TouchAreaInsets? = nil) { + super.init(frame: .zero) + self.touchAreaInsets = touchAreaInsets + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard let insets = touchAreaInsets else { + return super.point(inside: point, with: event) + } + + let area = bounds.insetBy(dx: insets.dx, dy: insets.dy) + + return area.contains(point) + } +} diff --git a/GliaWidgets/Sources/Component/Button/Button.swift b/GliaWidgets/Sources/Component/Button/Button.swift index 0e655ddf9..ca50431c4 100644 --- a/GliaWidgets/Sources/Component/Button/Button.swift +++ b/GliaWidgets/Sources/Component/Button/Button.swift @@ -1,8 +1,10 @@ import UIKit -class Button: UIButton { +class Button: AdjustedTouchAreaButton { var tap: (() -> Void)? + var touchAreaInsets: TouchAreaInsets? + override var isEnabled: Bool { didSet { super.isEnabled = isEnabled @@ -16,7 +18,7 @@ class Button: UIButton { init(kind: ButtonKind, tap: (() -> Void)? = nil) { self.kind = kind self.tap = tap - super.init(frame: .zero) + super.init(touchAreaInsets: kind.properties.touchAreaInsets) setup() layout() } @@ -58,14 +60,4 @@ class Button: UIButton { @objc private func tapped() { tap?() } - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - guard let insets = kind.properties.touchAreaInsets else { - return super.point(inside: point, with: event) - } - - let area = bounds.insetBy(dx: insets.dx, dy: insets.dy) - - return area.contains(point) - } } diff --git a/GliaWidgets/Sources/Theme/Theme+Gva.swift b/GliaWidgets/Sources/Theme/Theme+Gva.swift index 1ec90552a..0b5ec77ef 100644 --- a/GliaWidgets/Sources/Theme/Theme+Gva.swift +++ b/GliaWidgets/Sources/Theme/Theme+Gva.swift @@ -24,6 +24,18 @@ extension Theme { ) ) - return .init(persistentButton: persistentButton) + let quickReplyButtonStyle: GvaQuickReplyButtonStyle = .init( + textFont: font.buttonLabel, + textColor: Color.primary, + backgroundColor: .fill(color: Color.baseLight), + cornerRadius: 10, + borderColor: Color.primary, + borderWidth: 1 + ) + + return .init( + persistentButton: persistentButton, + quickReplyButtonStyle: quickReplyButtonStyle + ) } } diff --git a/GliaWidgets/Sources/View/Chat/ChatView.swift b/GliaWidgets/Sources/View/Chat/ChatView.swift index d90a15ff0..dbba67a77 100644 --- a/GliaWidgets/Sources/View/Chat/ChatView.swift +++ b/GliaWidgets/Sources/View/Chat/ChatView.swift @@ -25,6 +25,7 @@ class ChatView: EngagementView { var selectCustomCardOption: ((HtmlMetadata.Option, MessageRenderer.Message.Identifier) -> Void)? var gvaButtonTapped: ((GvaOption) -> Void)? + private lazy var quickReplyView = QuickReplyView(style: style.gliaVirtualAssistant.quickReplyButtonStyle) private let style: ChatStyle private var messageEntryViewBottomConstraint: NSLayoutConstraint! private var callBubble: BubbleView? @@ -161,6 +162,10 @@ class ChatView: EngagementView { typingIndicatorView.heightAnchor.constraint(equalToConstant: 10) ]) + addSubview(quickReplyView) + constraints += quickReplyView.layoutIn(safeAreaLayoutGuide, edges: .horizontal) + constraints += quickReplyView.topAnchor.constraint(equalTo: tableAndIndicatorStack.bottomAnchor) + addSubview(messageEntryView) let messageEntryInsets = UIEdgeInsets( top: 0, @@ -176,7 +181,7 @@ class ChatView: EngagementView { constraints += messageEntryViewBottomConstraint constraints += messageEntryView.layoutIn(safeAreaLayoutGuide, edges: .horizontal) - constraints += messageEntryView.topAnchor.constraint(equalTo: tableAndIndicatorStack.bottomAnchor) + constraints += messageEntryView.topAnchor.constraint(equalTo: quickReplyView.bottomAnchor) addSubview(unreadMessageIndicatorView) unreadMessageIndicatorView.translatesAutoresizingMaskIntoConstraints = false @@ -310,7 +315,11 @@ extension ChatView { tableView.reloadData() } - func renderHeaderProps() { + func renderQuickReply(props: QuickReplyView.Props) { + quickReplyView.props = props + } + + private func renderHeaderProps() { header.props = props.header } } @@ -371,24 +380,28 @@ extension ChatView { case .systemMessage(let message): return systemMessageContent(message) case let .gvaPersistentButton(message, button, showImage, imageUrl): - return gvaPersistenButtonContent( + return gvaPersistentButtonContent( message, button: button, showImage: showImage, imageUrl: imageUrl ) case let .gvaResponseText(message, text, showImage, imageUrl): - return gvaResponseTextContent( + let view = gvaResponseTextView( message, - text: text, + text: text.content, showImage: showImage, imageUrl: imageUrl ) - case let .gvaQuickReply(_, button): - // Temporary, since UI hasn't been implemented - let textView = UITextView() - textView.text = "Quick Reply: \(button.content)" - return .gvaQuickReply(textView) + return .gvaResponseText(view) + case let .gvaQuickReply(message, button, showImage, imageUrl): + let view = gvaResponseTextView( + message, + text: button.content, + showImage: showImage, + imageUrl: imageUrl + ) + return .gvaQuickReply(view) case let .gvaGallery(_, gallery): // Temporary, since UI hasn't been implemented let textView = UITextView() @@ -838,12 +851,12 @@ extension ChatView { return .unreadMessagesDivider(messageDivider) } - private func gvaResponseTextContent( + private func gvaResponseTextView( _ message: ChatMessage, - text: GvaResponseText, + text: NSAttributedString, showImage: Bool, imageUrl: String? - ) -> ChatItemCell.Content { + ) -> GvaResponseTextView { let view = GvaResponseTextView( with: style.operatorMessage, environment: .init( @@ -856,7 +869,7 @@ extension ChatView { ) view.appendContent( .attributedText( - text.content, + text, accessibility: Self.operatorAccessibilityMessage( for: message, operator: style.accessibility.operator, @@ -875,10 +888,10 @@ extension ChatView { view.linkTapped = { [weak self] in self?.linkTapped?($0) } view.showsOperatorImage = showImage view.setOperatorImage(fromUrl: imageUrl, animated: false) - return .gvaResponseText(view) + return view } - private func gvaPersistenButtonContent( + private func gvaPersistentButtonContent( _ message: ChatMessage, button: GvaButton, showImage: Bool, diff --git a/GliaWidgets/Sources/View/Chat/GVA/GvaStyle.swift b/GliaWidgets/Sources/View/Chat/GVA/GvaStyle.swift index 0977a958a..a718b775e 100644 --- a/GliaWidgets/Sources/View/Chat/GVA/GvaStyle.swift +++ b/GliaWidgets/Sources/View/Chat/GVA/GvaStyle.swift @@ -5,10 +5,18 @@ public struct GliaVirtualAssistantStyle { /// Style of Persistent Button public var persistentButton: GvaPersistentButtonStyle + /// Style for Quick Reply buttons. + public var quickReplyButtonStyle: GvaQuickReplyButtonStyle + + /// - Parameters: + /// - persistentButton: Style of Persistent Button + /// - quickReplyButtonStyle: Style for Quick Reply buttons. public init( - persistentButton: GvaPersistentButtonStyle + persistentButton: GvaPersistentButtonStyle, + quickReplyButtonStyle: GvaQuickReplyButtonStyle ) { self.persistentButton = persistentButton + self.quickReplyButtonStyle = quickReplyButtonStyle } mutating func apply( diff --git a/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonCell.swift b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonCell.swift new file mode 100644 index 000000000..ae540e3ca --- /dev/null +++ b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonCell.swift @@ -0,0 +1,76 @@ +import UIKit + +final class QuickReplyButtonCell: UICollectionViewCell { + static let identifier = "QuickReplyButtonCell" + + var props: Props = .nop { + didSet { + button.setTitle(props.title, for: .normal) + button.accessibilityLabel = props.title + } + } + + var style: GvaQuickReplyButtonStyle? { + didSet { + guard let style else { return } + applyStyle(style) + } + } + + private lazy var button: AdjustedTouchAreaButton = { + let button = AdjustedTouchAreaButton(touchAreaInsets: (dx: 0, dy: -8)) + button.translatesAutoresizingMaskIntoConstraints = false + button.contentEdgeInsets = .init(top: 6, left: 16, bottom: 6, right: 16) + button.addTarget(self, action: #selector(tap), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + button.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tap() { + props.action() + } +} + +// MARK: - Private + +private extension QuickReplyButtonCell { + func applyStyle(_ style: GvaQuickReplyButtonStyle) { + switch style.backgroundColor { + case .fill(let color): + button.backgroundColor = color + case .gradient(let colors): + button.makeGradientBackground(colors: colors) + } + button.setTitleColor(style.textColor, for: .normal) + button.titleLabel?.font = style.textFont + button.layer.cornerRadius = style.cornerRadius + button.layer.borderColor = style.borderColor.cgColor + button.layer.borderWidth = style.borderWidth + } +} + +// MARK: - Props + +extension QuickReplyButtonCell { + struct Props { + let title: String + let action: Cmd + + static var nop: Props { .init(title: "", action: .nop) } + } +} diff --git a/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonStyle.swift b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonStyle.swift new file mode 100644 index 000000000..80da59cf3 --- /dev/null +++ b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyButtonStyle.swift @@ -0,0 +1,43 @@ +import UIKit + +/// Style of the GVA Quick Reply Button +public struct GvaQuickReplyButtonStyle { + /// Font of the button + public var textFont: UIFont + + /// Color of the button + public var textColor: UIColor + + /// Text style of the button + public var textStyle: UIFont.TextStyle + + /// Background of the button + public var backgroundColor: ColorType + + /// Corner radius of the button + public var cornerRadius: CGFloat + + /// Border color of the button + public var borderColor: UIColor + + /// Border width of the button + public var borderWidth: CGFloat + + init( + textFont: UIFont, + textColor: UIColor, + textStyle: UIFont.TextStyle = .title2, + backgroundColor: ColorType, + cornerRadius: CGFloat, + borderColor: UIColor, + borderWidth: CGFloat + ) { + self.textFont = textFont + self.textColor = textColor + self.textStyle = textStyle + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius + self.borderColor = borderColor + self.borderWidth = borderWidth + } +} diff --git a/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyView.swift b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyView.swift new file mode 100644 index 000000000..a0d2a58d3 --- /dev/null +++ b/GliaWidgets/Sources/View/Chat/GVA/QuickReply/QuickReplyView.swift @@ -0,0 +1,120 @@ +import UIKit + +final class QuickReplyView: BaseView { + var props: Props = .hidden { + didSet { + renderProps() + } + } + + let style: GvaQuickReplyButtonStyle + private lazy var collectionView: SelfSizingCollectionView = { + let layout = LeftAlignedCollectionViewFlowLayout() + layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 12 + layout.sectionInset = .init(top: 8, left: 16, bottom: 8, right: 16) + return SelfSizingCollectionView( + frame: .zero, + collectionViewLayout: layout + ) + }() + private var collectionViewHeightConstraint: NSLayoutConstraint? + + init(style: GvaQuickReplyButtonStyle) { + self.style = style + super.init() + } + + @available(*, unavailable) + required init() { + fatalError("init() has not been implemented") + } + + override func setup() { + super.setup() + collectionView.register( + QuickReplyButtonCell.self, + forCellWithReuseIdentifier: QuickReplyButtonCell.identifier + ) + collectionView.dataSource = self + + addSubview(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + } + + override func defineLayout() { + super.defineLayout() + let height = collectionView.heightAnchor.constraint(equalToConstant: 0) + height.priority = .required + collectionViewHeightConstraint = height + + NSLayoutConstraint.activate([ + height, + collectionView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + collectionView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor) + ]) + } +} + +// MARK: - Private + +private extension QuickReplyView { + func renderProps() { + UIView.animate(withDuration: 0.3) { + switch self.props { + case .shown: + self.collectionViewHeightConstraint?.priority = .defaultLow + case .hidden: + self.collectionViewHeightConstraint?.priority = .required + } + self.layoutIfNeeded() + } + collectionView.reloadData() + } +} + +// MARK: - UICollectionViewDataSource + +extension QuickReplyView: UICollectionViewDataSource { + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + props.buttons?.count ?? 0 + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let props = props.buttons, + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: QuickReplyButtonCell.identifier, + for: indexPath + ) as? QuickReplyButtonCell else { + return UICollectionViewCell() + } + cell.style = style + cell.props = props[indexPath.item] + return cell + } +} + +// MARK: - Props + +extension QuickReplyView { + enum Props { + case shown([QuickReplyButtonCell.Props]) + case hidden + + var buttons: [QuickReplyButtonCell.Props]? { + switch self { + case let .shown(buttons): return buttons + case .hidden: return nil + } + } + } +} diff --git a/GliaWidgets/Sources/View/Common/LeftAlignedCollectionViewFlowLayout/LeftAlignedCollectionViewFlowLayout.swift b/GliaWidgets/Sources/View/Common/LeftAlignedCollectionViewFlowLayout/LeftAlignedCollectionViewFlowLayout.swift new file mode 100644 index 000000000..7519926eb --- /dev/null +++ b/GliaWidgets/Sources/View/Common/LeftAlignedCollectionViewFlowLayout/LeftAlignedCollectionViewFlowLayout.swift @@ -0,0 +1,22 @@ +import UIKit + +final class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributes = super.layoutAttributesForElements(in: rect) + + var leftMargin = sectionInset.left + var maxY: CGFloat = -1.0 + attributes?.forEach { layoutAttribute in + if layoutAttribute.frame.origin.y >= maxY { + leftMargin = sectionInset.left + } + + layoutAttribute.frame.origin.x = leftMargin + + leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing + maxY = max(layoutAttribute.frame.maxY, maxY) + } + + return attributes + } +} diff --git a/GliaWidgets/Sources/View/Common/SelfSizingCollectionView/SelfSizingCollectionView.swift b/GliaWidgets/Sources/View/Common/SelfSizingCollectionView/SelfSizingCollectionView.swift new file mode 100644 index 000000000..8bd3fd424 --- /dev/null +++ b/GliaWidgets/Sources/View/Common/SelfSizingCollectionView/SelfSizingCollectionView.swift @@ -0,0 +1,32 @@ +import UIKit + +final class SelfSizingCollectionView: UICollectionView { + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: layout) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + private func commonInit() { + isScrollEnabled = false + } + + override var contentSize: CGSize { + didSet { + invalidateIntrinsicContentSize() + } + } + + override func reloadData() { + super.reloadData() + self.invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + return contentSize + } +} diff --git a/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift b/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift index 52d380045..4f1d00428 100644 --- a/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift +++ b/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift @@ -166,6 +166,8 @@ final class ChatViewController: EngagementViewController, PopoverPresenter { view?.unreadMessageIndicatorView.setImage(fromUrl: imageUrl, animated: true) case let .fileUploadListPropsUpdated(fileUploadListProps): view?.messageEntryView.uploadListView.props = fileUploadListProps + case let .quickReplyPropsUpdated(props): + view?.renderQuickReply(props: props) } self?.renderProps() } diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift index 068d1f9ec..e4c3462b0 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift @@ -7,6 +7,17 @@ private extension String { } extension ChatViewModel { + func quickReplyOption(_ gvaOption: GvaOption) -> QuickReplyButtonCell.Props { + let action = Cmd { [weak self] in + self?.gvaOptionAction(for: gvaOption)() + self?.action?(.quickReplyPropsUpdated(.hidden)) + } + return .init( + title: gvaOption.text, + action: action + ) + } + func gvaOptionAction(for option: GvaOption) -> Cmd { // If `option.destinationPdBroadcastEvent` is specified, // this is broadcast event button, which is not supported @@ -23,6 +34,33 @@ extension ChatViewModel { return postbackButtonAction(for: option) } } + + func postbackButtonAction(for option: GvaOption) -> Cmd { + .init { [weak self] in + guard let value = option.value else { return } + let singleChoiceOption = SingleChoiceOption(text: option.text, value: value) + self?.environment.sendSelectedOptionValue(singleChoiceOption) { [weak self] result in + guard let self = self else { return } + switch result { + case let .success(message): + let chatMessage = ChatMessage(with: message) + let item = ChatItem( + with: chatMessage, + isCustomCardSupported: self.isCustomCardSupported + ) + if let item { + self.appendItem(item, to: self.messagesSection, animated: true) + } + self.action?(.scrollToBottom(animated: true)) + case .failure: + self.showAlert( + with: self.alertConfiguration.unexpectedError, + dismissed: nil + ) + } + } + } + } } private extension ChatViewModel { @@ -58,31 +96,6 @@ private extension ChatViewModel { } } - func postbackButtonAction(for option: GvaOption) -> Cmd { - .init { [weak self] in - guard let value = option.value else { return } - let singleChoiceOption = SingleChoiceOption(text: option.text, value: value) - self?.environment.sendSelectedOptionValue(singleChoiceOption) { [weak self] result in - guard let self = self else { return } - switch result { - case let .success(message): - let chatMessage = ChatMessage(with: message) - if let item = ChatItem( - with: chatMessage, - isCustomCardSupported: self.isCustomCardSupported - ) { - self.appendItem(item, to: self.messagesSection, animated: true) - } - case .failure: - self.showAlert( - with: self.alertConfiguration.unexpectedError, - dismissed: nil - ) - } - } - } - } - func broadcastEventButtonAction() -> Cmd { .init { [weak self] in guard let self = self else { return } diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift index 6b5e4ab1d..8698e2d7e 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift @@ -577,9 +577,17 @@ extension ChatViewModel { if isChatBottomReached { action?(.scrollToBottom(animated: true)) } + + if case .gvaQuickReply(_, let button, _, _) = item.kind { + let props = button.options.map { quickReplyOption($0) } + action?(.quickReplyPropsUpdated(.shown(props))) + } } default: - break + // All Quick Reply buttons of the same set should disappear + // after the user taps on one of the buttons or when + // there is a new message from the user or GVA + action?(.quickReplyPropsUpdated(.hidden)) } } @@ -861,6 +869,7 @@ extension ChatViewModel { case setOperatorTypingIndicatorIsHiddenTo(Bool, _ isChatScrolledToBottom: Bool) case setAttachmentButtonVisibility(MediaPickerButtonVisibility) case fileUploadListPropsUpdated(SecureConversations.FileUploadListView.Props) + case quickReplyPropsUpdated(QuickReplyView.Props) } enum DelegateEvent { diff --git a/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift b/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift index 7b6fbe5c7..7a82a9d24 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift @@ -54,7 +54,7 @@ class ChatItem { case let .gvaResponseText(text): kind = .gvaResponseText(message, responseText: text, showImage: true, imageUrl: message.operator?.pictureUrl) case let .gvaQuickReply(button): - kind = .gvaQuickReply(message, quickReply: button) + kind = .gvaQuickReply(message, quickReply: button, showImage: true, imageUrl: message.operator?.pictureUrl) case let .gvaGallery(gallery): kind = .gvaGallery(message, gallery: gallery) case .none: @@ -85,7 +85,7 @@ extension ChatItem { case systemMessage(ChatMessage) case gvaPersistentButton(ChatMessage, persistenButton: GvaButton, showImage: Bool, imageUrl: String?) case gvaResponseText(ChatMessage, responseText: GvaResponseText, showImage: Bool, imageUrl: String?) - case gvaQuickReply(ChatMessage, quickReply: GvaButton) + case gvaQuickReply(ChatMessage, quickReply: GvaButton, showImage: Bool, imageUrl: String?) case gvaGallery(ChatMessage, gallery: GvaGallery) } }