AzureCommunicationUI/AzureCommunicationUIDemoApp/Sources/Views/ChatDemoViewController.swift (525 lines of code) (raw):

// // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // import UIKit import Combine import SwiftUI import AzureCommunicationUIChat import AzureCommunicationChat import AzureCommunicationCommon class ChatDemoViewController: UIViewController { enum LayoutConstants { static let verticalSpacing: CGFloat = 8.0 static let stackViewSpacingPortrait: CGFloat = 18.0 static let stackViewSpacingLandscape: CGFloat = 12.0 static let buttonHorizontalInset: CGFloat = 20.0 static let buttonVerticalInset: CGFloat = 10.0 } private var selectedAcsTokenType: ACSTokenType = .token private var acsTokenUrlTextField: UITextField! private var acsTokenTextField: UITextField! private var displayNameTextField: UITextField! private var userIdTextField: UITextField! private var endpointUrlTextField: UITextField! private var chatThreadIdTextField: UITextField! private var startExperienceButton: UIButton! private var stopButton: UIButton! private var acsTokenTypeSegmentedControl: UISegmentedControl! private var stackView: UIStackView! private var titleLabel: UILabel! private var titleLabelConstraint: NSLayoutConstraint! // The space needed to fill the top part of the stack view, // in order to make the stackview content centered private var spaceToFullInStackView: CGFloat? private var userIsEditing = false private var isKeyboardShowing = false private var cancellable = Set<AnyCancellable>() private var envConfigSubject: EnvConfigSubject var chatAdapter: ChatAdapter? var threadId: String? private lazy var contentView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false return view }() private lazy var scrollView: UIScrollView = { let view = UIScrollView() view.translatesAutoresizingMaskIntoConstraints = false view.showsVerticalScrollIndicator = false view.showsHorizontalScrollIndicator = false return view }() override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) updateUIBasedOnUserInterfaceStyle() if UIDevice.current.orientation.isPortrait { stackView.spacing = LayoutConstants.stackViewSpacingPortrait titleLabelConstraint.constant = 32 } else if UIDevice.current.orientation.isLandscape { stackView.spacing = LayoutConstants.stackViewSpacingLandscape titleLabelConstraint.constant = 16.0 } } init(envConfigSubject: EnvConfigSubject) { self.envConfigSubject = envConfigSubject super.init(nibName: nil, bundle: nil) self.combineEnvConfigSubject() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setupUI() registerNotifications() } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() guard !userIsEditing else { return } scrollView.setNeedsLayout() scrollView.layoutIfNeeded() let emptySpace = stackView.customSpacing(after: stackView.arrangedSubviews.first!) let spaceToFill = (scrollView.frame.height - (stackView.frame.height - emptySpace)) / 2 stackView.setCustomSpacing(spaceToFill + LayoutConstants.verticalSpacing, after: stackView.arrangedSubviews.first!) } func combineEnvConfigSubject() { envConfigSubject.objectWillChange .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true).sink(receiveValue: { [weak self] _ in self?.updateFromEnvConfig() }).store(in: &cancellable) } func updateFromEnvConfig() { if envConfigSubject.useExpiredToken { updateToken(envConfigSubject.expiredAcsToken) } else { updateToken(envConfigSubject.acsToken) } if !envConfigSubject.displayName.isEmpty { displayNameTextField.text = envConfigSubject.displayName } if !envConfigSubject.userId.isEmpty { userIdTextField.text = envConfigSubject.userId } if !envConfigSubject.endpointUrl.isEmpty { endpointUrlTextField.text = envConfigSubject.endpointUrl } if !envConfigSubject.threadId.isEmpty { chatThreadIdTextField.text = envConfigSubject.threadId } } private func updateToken(_ token: String) { if !token.isEmpty { acsTokenTextField.text = token acsTokenTypeSegmentedControl.selectedSegmentIndex = 1 } } @objc func onBackBtnPressed() { Task { @MainActor in print("Dismissing chat") self.dismiss(animated: true, completion: { [weak self] in self?.chatAdapter?.disconnect(completionHandler: { [weak self] result in guard let self else { return } self.onDisconnectFromChat(with: result) }) }) } } func startExperience(with link: String) async { print("Chat is starting \(link)") let communicationIdentifier = CommunicationUserIdentifier(envConfigSubject.userId) guard let communicationTokenCredential = try? CommunicationTokenCredential( token: envConfigSubject.acsToken) else { return } self.threadId = envConfigSubject.threadId self.chatAdapter = ChatAdapter( endpoint: envConfigSubject.endpointUrl, identifier: communicationIdentifier, credential: communicationTokenCredential, threadId: envConfigSubject.threadId, displayName: envConfigSubject.displayName) setupErrorHandler() } private func setupErrorHandler() { guard let adapter = self.chatAdapter else { return } adapter.events.onError = { [weak self] chatCompositeError in guard let self else { return } print("::::UIKitChatDemoView::setupErrorHandler::onError \(chatCompositeError)") print("::::UIKitChatDemoView error.code \(chatCompositeError.code)") print("Error - \(chatCompositeError.code): " + "\(chatCompositeError.error?.localizedDescription ?? chatCompositeError.localizedDescription)") self.showError(errorCode: chatCompositeError.code) } } private func onDisconnectFromChat(with result: Result<Void, ChatCompositeError>) { switch result { case .success: self.chatAdapter = nil self.updateExperieceButton() self.startExperienceButton.isEnabled = true case .failure(let error): print("disconnect error \(error)") } } private func showError(errorCode: String) { var errorMessage = "" switch errorCode { case ChatCompositeErrorCode.joinFailed: errorMessage = "Connection Failed" case ChatCompositeErrorCode.disconnectFailed: errorMessage = "Disconnect Failed" case ChatCompositeErrorCode.sendMessageFailed, ChatCompositeErrorCode.fetchMessagesFailed, ChatCompositeErrorCode.requestParticipantsFetchFailed, ChatCompositeErrorCode.sendReadReceiptFailed, ChatCompositeErrorCode.sendTypingIndicatorFailed: // no alert return default: errorMessage = "Unknown error" } let errorAlert = UIAlertController(title: "Error", message: errorMessage, preferredStyle: .alert) errorAlert.addAction(UIAlertAction(title: "Dismiss", style: .cancel, handler: { [weak self] _ in guard let self else { return } self.chatAdapter?.disconnect(completionHandler: { [weak self] result in guard let self else { return } self.onDisconnectFromChat(with: result) }) })) present(errorAlert, animated: true, completion: nil) } private func getTokenCredential() async throws -> CommunicationTokenCredential { switch selectedAcsTokenType { case .token: if let communicationTokenCredential = try? CommunicationTokenCredential(token: acsTokenTextField.text!) { return communicationTokenCredential } else { throw DemoError.invalidToken } case .tokenUrl: if let url = URL(string: acsTokenUrlTextField.text!) { let tokenRefresher = AuthenticationHelper.getCommunicationToken(tokenUrl: url) let initialToken = await AuthenticationHelper.fetchInitialToken(with: tokenRefresher) let refreshOptions = CommunicationTokenRefreshOptions(initialToken: initialToken, refreshProactively: true, tokenRefresher: tokenRefresher) if let credential = try? CommunicationTokenCredential(withOptions: refreshOptions) { return credential } } throw DemoError.invalidToken } } private func getDisplayName() -> String { displayNameTextField.text ?? "" } private func getMeetingLink() -> String { return chatThreadIdTextField.text ?? "" } private func registerNotifications() { let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) } private func updateUIBasedOnUserInterfaceStyle() { if UITraitCollection.current.userInterfaceStyle == .dark { view.backgroundColor = .black } else { view.backgroundColor = .white } } @objc func onAcsTokenTypeValueChanged(_ sender: UISegmentedControl!) { selectedAcsTokenType = ACSTokenType(rawValue: sender.selectedSegmentIndex)! updateAcsTokenTypeFields() } @objc func keyboardWillShow(notification: NSNotification) { isKeyboardShowing = true adjustScrollView() } @objc func keyboardWillHide(notification: NSNotification) { userIsEditing = false isKeyboardShowing = false adjustScrollView() } @objc func textFieldEditingDidChange() { startExperienceButton.isEnabled = !isStartExperienceDisabled updateExperieceButton() } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { userIsEditing = true return true } @objc func onStartExperienceBtnPressed() { startExperienceButton.isEnabled = false startExperienceButton.backgroundColor = .systemGray3 let link = self.getMeetingLink() Task { @MainActor in if self.chatAdapter == nil { await self.startExperience(with: link) } guard let chatAdapter = self.chatAdapter else { return } try await chatAdapter.connect() let chatCompositeViewController = ChatCompositeViewController( with: chatAdapter) let closeItem = UIBarButtonItem( barButtonSystemItem: .close, target: nil, action: #selector(onBackBtnPressed)) chatCompositeViewController.title = "Chat" chatCompositeViewController.navigationItem.leftBarButtonItem = closeItem let navController = NavigationController(rootViewController: chatCompositeViewController) navController.modalPresentationStyle = .fullScreen present(navController, animated: true, completion: nil) startExperienceButton.isEnabled = true startExperienceButton.backgroundColor = .systemBlue updateExperieceButton() } } @objc func onStopBtnPressed() { Task { @MainActor in self.chatAdapter?.disconnect(completionHandler: { [weak self] result in guard let self else { return } self.onDisconnectFromChat(with: result) }) updateExperieceButton() } } private func updateAcsTokenTypeFields() { switch selectedAcsTokenType { case .tokenUrl: acsTokenUrlTextField.isHidden = false acsTokenTextField.isHidden = true case .token: acsTokenUrlTextField.isHidden = true acsTokenTextField.isHidden = false } } private func updateExperieceButton() { if isStartExperienceDisabled { startExperienceButton.backgroundColor = .systemGray3 } else { startExperienceButton.backgroundColor = .systemBlue } if self.chatAdapter == nil { stopButton.backgroundColor = .systemGray3 stopButton.isEnabled = false } else { stopButton.backgroundColor = .systemBlue stopButton.isEnabled = true } } private var isStartExperienceDisabled: Bool { if (selectedAcsTokenType == .token && acsTokenTextField.text!.isEmpty) || (selectedAcsTokenType == .tokenUrl && acsTokenUrlTextField.text!.isEmpty) || (userIdTextField.text!.isEmpty) || (endpointUrlTextField.text!.isEmpty) || (chatThreadIdTextField.text!.isEmpty) { return true } return false } private func setupUI() { updateUIBasedOnUserInterfaceStyle() let safeArea = view.safeAreaLayoutGuide titleLabel = UILabel() titleLabel.text = "UI Library - UIKit Sample" titleLabel.sizeToFit() titleLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(titleLabel) titleLabelConstraint = titleLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: LayoutConstants.stackViewSpacingPortrait) titleLabelConstraint.isActive = true titleLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor).isActive = true acsTokenUrlTextField = UITextField() acsTokenUrlTextField.placeholder = "ACS Token URL" acsTokenUrlTextField.text = envConfigSubject.acsTokenUrl acsTokenUrlTextField.delegate = self acsTokenUrlTextField.sizeToFit() acsTokenUrlTextField.translatesAutoresizingMaskIntoConstraints = false acsTokenUrlTextField.borderStyle = .roundedRect acsTokenUrlTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) acsTokenTextField = UITextField() acsTokenTextField.placeholder = "ACS Token" acsTokenTextField.text = envConfigSubject.acsToken acsTokenTextField.delegate = self acsTokenTextField.sizeToFit() acsTokenTextField.translatesAutoresizingMaskIntoConstraints = false acsTokenTextField.borderStyle = .roundedRect acsTokenTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) acsTokenTypeSegmentedControl = UISegmentedControl(items: ["Token URL", "Token"]) acsTokenTypeSegmentedControl.selectedSegmentIndex = envConfigSubject.selectedAcsTokenType.rawValue acsTokenTypeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false acsTokenTypeSegmentedControl.addTarget(self, action: #selector(onAcsTokenTypeValueChanged(_:)), for: .valueChanged) selectedAcsTokenType = envConfigSubject.selectedAcsTokenType displayNameTextField = UITextField() displayNameTextField.placeholder = "Display Name" displayNameTextField.text = envConfigSubject.displayName displayNameTextField.translatesAutoresizingMaskIntoConstraints = false displayNameTextField.delegate = self displayNameTextField.borderStyle = .roundedRect displayNameTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) userIdTextField = UITextField() userIdTextField.placeholder = "Communication User Id" userIdTextField.text = envConfigSubject.userId userIdTextField.translatesAutoresizingMaskIntoConstraints = false userIdTextField.delegate = self userIdTextField.borderStyle = .roundedRect userIdTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) endpointUrlTextField = UITextField() endpointUrlTextField.placeholder = "Endpoint Url" endpointUrlTextField.text = envConfigSubject.endpointUrl endpointUrlTextField.translatesAutoresizingMaskIntoConstraints = false endpointUrlTextField.delegate = self endpointUrlTextField.borderStyle = .roundedRect endpointUrlTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) chatThreadIdTextField = UITextField() chatThreadIdTextField.placeholder = "ThreadId Id" chatThreadIdTextField.text = envConfigSubject.threadId chatThreadIdTextField.delegate = self chatThreadIdTextField.sizeToFit() chatThreadIdTextField.translatesAutoresizingMaskIntoConstraints = false chatThreadIdTextField.borderStyle = .roundedRect chatThreadIdTextField.addTarget(self, action: #selector(textFieldEditingDidChange), for: .editingChanged) startExperienceButton = UIButton() startExperienceButton.backgroundColor = .systemBlue startExperienceButton.setTitleColor(UIColor.white, for: .normal) startExperienceButton.setTitleColor(UIColor.systemGray6, for: .disabled) startExperienceButton.contentEdgeInsets = UIEdgeInsets.init(top: LayoutConstants.buttonVerticalInset, left: LayoutConstants.buttonHorizontalInset, bottom: LayoutConstants.buttonVerticalInset, right: LayoutConstants.buttonHorizontalInset) startExperienceButton.layer.cornerRadius = 8 startExperienceButton.setTitle("Start Experience", for: .normal) startExperienceButton.sizeToFit() startExperienceButton.translatesAutoresizingMaskIntoConstraints = false startExperienceButton.addTarget(self, action: #selector(onStartExperienceBtnPressed), for: .touchUpInside) startExperienceButton.accessibilityLabel = AccessibilityId.startExperienceAccessibilityID.rawValue stopButton = UIButton() stopButton.backgroundColor = .systemGray6 stopButton.setTitleColor(UIColor.white, for: .normal) stopButton.setTitleColor(UIColor.systemGray6, for: .disabled) stopButton.contentEdgeInsets = UIEdgeInsets.init(top: LayoutConstants.buttonVerticalInset, left: LayoutConstants.buttonHorizontalInset, bottom: LayoutConstants.buttonVerticalInset, right: LayoutConstants.buttonHorizontalInset) stopButton.layer.cornerRadius = 8 stopButton.setTitle("Stop", for: .normal) stopButton.sizeToFit() stopButton.translatesAutoresizingMaskIntoConstraints = false stopButton.addTarget(self, action: #selector(onStopBtnPressed), for: .touchUpInside) stopButton.accessibilityLabel = AccessibilityId.stopChatAccessibilityID.rawValue let startButtonHSpacer1 = UIView() startButtonHSpacer1.translatesAutoresizingMaskIntoConstraints = false startButtonHSpacer1.setContentHuggingPriority(.defaultLow, for: .horizontal) let startButtonHSpacer2 = UIView() startButtonHSpacer2.translatesAutoresizingMaskIntoConstraints = false startButtonHSpacer2.setContentHuggingPriority(.defaultLow, for: .horizontal) let startButtonHStack = UIStackView(arrangedSubviews: [startButtonHSpacer1, startExperienceButton, startButtonHSpacer2, stopButton, startButtonHSpacer2]) startButtonHStack.axis = .horizontal startButtonHStack.alignment = .fill startButtonHStack.distribution = .fill startButtonHStack.translatesAutoresizingMaskIntoConstraints = false let spaceView1 = UIView() spaceView1.translatesAutoresizingMaskIntoConstraints = false spaceView1.heightAnchor.constraint(equalToConstant: 0).isActive = true stackView = UIStackView(arrangedSubviews: [spaceView1, acsTokenTypeSegmentedControl, acsTokenUrlTextField, acsTokenTextField, displayNameTextField, userIdTextField, endpointUrlTextField, chatThreadIdTextField, startButtonHStack]) stackView.spacing = LayoutConstants.stackViewSpacingPortrait stackView.axis = .vertical stackView.alignment = .fill stackView.distribution = .fill stackView.translatesAutoresizingMaskIntoConstraints = false stackView.setCustomSpacing(0, after: stackView.arrangedSubviews.first!) view.addSubview(scrollView) scrollView.addSubview(contentView) scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: LayoutConstants.verticalSpacing).isActive = true scrollView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor).isActive = true scrollView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor).isActive = true scrollView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor).isActive = true contentView.addSubview(stackView) contentView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true stackView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: LayoutConstants.stackViewSpacingPortrait).isActive = true stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: LayoutConstants.stackViewSpacingPortrait).isActive = true stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true startButtonHSpacer2.widthAnchor.constraint(equalTo: startButtonHSpacer1.widthAnchor).isActive = true updateAcsTokenTypeFields() startExperienceButton.isEnabled = !isStartExperienceDisabled updateExperieceButton() } private func adjustScrollView() { if UIDevice.current.userInterfaceIdiom == .phone || UIDevice.current.orientation.isLandscape { if self.isKeyboardShowing { let offset: CGFloat = (UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown) ? 200 : 250 let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0) scrollView.contentInset = contentInsets scrollView.scrollIndicatorInsets = contentInsets scrollView.setContentOffset(CGPoint(x: 0, y: offset), animated: true) } else { scrollView.contentInset = .zero scrollView.scrollIndicatorInsets = .zero } } } } extension ChatDemoViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return false } } class NavigationController: UINavigationController { override var shouldAutorotate: Bool { false } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .portrait } override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .portrait } }