// // UserListView.swift // TUIRoomKit // // Created by janejntang on 2023/1/4. // Copyright © 2023 Tencent. All rights reserved. // import Foundation import Factory import Combine import RTCRoomEngine class UserListView: UIView { let viewModel: UserListViewModel let memberLabel: UILabel = { let label = UILabel() label.textColor = UIColor(0xD5E0F2) label.font = UIFont(name: "PingFangSC-Regular", size: 16) return label }() let searchBar: UISearchBar = { let searchBar = UISearchBar() searchBar.placeholder = .searchMemberText searchBar.setBackgroundImage(UIColor(0x17181F).trans2Image(), for: .top, barMetrics: .default) if #available(iOS 13, *) { searchBar.searchTextField.textColor = UIColor(0xB2BBD1) searchBar.searchTextField.tintColor = UIColor(0xB2BBD1).withAlphaComponent(0.3) searchBar.searchTextField.layer.cornerRadius = 6 searchBar.searchTextField.font = UIFont.systemFont(ofSize: 14, weight: .medium) } else { if let textField = searchBar.value(forKey: "searchField") as? UITextField { textField.textColor = UIColor(0xB2BBD1) textField.tintColor = UIColor(0xB2BBD1).withAlphaComponent(0.3) textField.layer.cornerRadius = 6 textField.font = UIFont.systemFont(ofSize: 14, weight: .medium) } } return searchBar }() let searchControl: UIControl = { let view = UIControl() view.backgroundColor = .clear view.isHidden = true return view }() let listStateView: UIView = { let view = UIView(frame: .zero) view.backgroundColor = UIColor(0x4F586B).withAlphaComponent(0.3) view.layer.cornerRadius = 6 return view }() let haveEnteredButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(UIColor(0xD5E0F2), for: .normal) button.titleLabel?.font = UIFont(name: "PingFangSC-Regular", size: 14) button.setBackgroundImage(UIColor(0xD5E0F2).withAlphaComponent(0.3).trans2Image(), for: .selected) button.setBackgroundImage(UIColor.clear.trans2Image(), for: .normal) button.layer.cornerRadius = 6 button.clipsToBounds = true button.isSelected = true return button }() let onStageButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(UIColor(0xD5E0F2), for: .normal) button.titleLabel?.font = UIFont(name: "PingFangSC-Regular", size: 14) button.setBackgroundImage(UIColor(0xD5E0F2).withAlphaComponent(0.3).trans2Image(), for: .selected) button.setBackgroundImage(UIColor.clear.trans2Image(), for: .normal) button.layer.cornerRadius = 6 button.clipsToBounds = true button.isSelected = true return button }() let offStageButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(UIColor(0xD5E0F2), for: .normal) button.titleLabel?.font = UIFont(name: "PingFangSC-Regular", size: 14) button.setBackgroundImage(UIColor(0xD5E0F2).withAlphaComponent(0.3).trans2Image(), for: .selected) button.setBackgroundImage(UIColor.clear.trans2Image(), for: .normal) button.layer.cornerRadius = 6 button.clipsToBounds = true button.isSelected = false return button }() let invatationListButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(UIColor(0xD5E0F2), for: .normal) button.titleLabel?.font = UIFont(name: "PingFangSC-Regular", size: 14) button.setBackgroundImage(UIColor(0xD5E0F2).withAlphaComponent(0.3).trans2Image(), for: .selected) button.setBackgroundImage(UIColor.clear.trans2Image(), for: .normal) button.layer.cornerRadius = 6 button.clipsToBounds = true button.isSelected = false return button }() let muteAllAudioButton: UIButton = { let button = UIButton(type: .custom) button.titleLabel?.font = UIFont.systemFont(ofSize: 14) button.setTitle(.allMuteAudioText, for: .normal) button.setTitleColor(UIColor(0xB2BBD1), for: .normal) button.setTitle(.allUnMuteAudioText, for: .selected) button.setTitleColor(UIColor(0xF2504B), for: .selected) button.setTitle(.allUnMuteAudioText, for: [.selected, .highlighted]) button.setTitleColor(UIColor(0xF2504B), for: [.selected, .highlighted]) button.backgroundColor = UIColor(0x4F586B, alpha: 0.3) button.layer.cornerRadius = 6 button.clipsToBounds = true button.titleLabel?.adjustsFontSizeToFitWidth = true return button }() let muteAllVideoButton: UIButton = { let button = UIButton(type: .custom) button.titleLabel?.font = UIFont.systemFont(ofSize: 14) button.setTitle(.allMuteVideoText, for: .normal) button.setTitleColor(UIColor(0xB2BBD1), for: .normal) button.setTitle(.allUnMuteVideoText, for: .selected) button.setTitleColor(UIColor(0xF2504B), for: .selected) button.setTitle(.allUnMuteVideoText, for: [.selected, .highlighted]) button.setTitleColor(UIColor(0xF2504B), for: [.selected, .highlighted]) button.backgroundColor = UIColor(0x4F586B, alpha: 0.3) button.layer.cornerRadius = 6 button.clipsToBounds = true button.titleLabel?.adjustsFontSizeToFitWidth = true return button }() let moreFunctionButton: UIButton = { let button = UIButton(type: .custom) button.titleLabel?.font = UIFont.systemFont(ofSize: 14) button.setTitle(.moreText, for: .normal) button.setTitleColor(UIColor(0xB2BBD1), for: .normal) button.backgroundColor = UIColor(0x4F586B, alpha: 0.3) button.layer.cornerRadius = 6 button.clipsToBounds = true button.titleLabel?.adjustsFontSizeToFitWidth = true return button }() let callEveryoneButton: UIButton = { let button = UIButton(type: .custom) button.titleLabel?.font = UIFont.systemFont(ofSize: 14) button.setTitle(.callEveryoneText, for: .normal) button.setTitleColor(UIColor(0xB2BBD1), for: .normal) button.backgroundColor = UIColor(0x4F586B) button.layer.cornerRadius = 6 button.clipsToBounds = true button.isHidden = true return button }() let bottomControlView: UIView = { let view = UIView() view.backgroundColor = UIColor(0x17181F) return view }() lazy var userListTableView: UITableView = { let tableView = UITableView(frame: .zero, style: .plain) tableView.separatorStyle = .none tableView.delegate = self tableView.dataSource = self tableView.backgroundColor = UIColor(0x17181F) tableView.register(UserListCell.self, forCellReuseIdentifier: "UserListCell") return tableView }() lazy var raiseHandNotificationView: RaiseHandApplicationNotificationView = { let viewModel = RaiseHandApplicationNotificationViewModel() viewModel.delayDisappearanceTime = 0 let applicationNotificationView = RaiseHandApplicationNotificationView(viewModel: viewModel) applicationNotificationView.delegate = self.viewModel return applicationNotificationView }() private lazy var invitationListPublisher = { conferenceStore.select(ConferenceInvitationSelectors.getInvitationList) }() var cancellableSet = Set() init(viewModel: UserListViewModel) { self.viewModel = viewModel super.init(frame: .zero) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var isViewReady: Bool = false override func didMoveToWindow() { super.didMoveToWindow() guard !isViewReady else { return } isViewReady = true backgroundColor = UIColor(0x17181F) constructViewHierarchy() activateConstraints() bindInteraction() reportViewShow() } func constructViewHierarchy() { addSubview(memberLabel) addSubview(searchBar) addSubview(listStateView) listStateView.addSubview(haveEnteredButton) listStateView.addSubview(onStageButton) listStateView.addSubview(offStageButton) listStateView.addSubview(invatationListButton) addSubview(raiseHandNotificationView) addSubview(userListTableView) addSubview(bottomControlView) bottomControlView.addSubview(muteAllAudioButton) bottomControlView.addSubview(muteAllVideoButton) bottomControlView.addSubview(moreFunctionButton) addSubview(callEveryoneButton) addSubview(searchControl) } func activateConstraints() { memberLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10.scale375Height()) make.leading.equalToSuperview().offset(16.scale375()) make.trailing.equalToSuperview().offset(-16.scale375()) make.height.equalTo(24.scale375Height()) } searchBar.snp.makeConstraints { make in make.top.equalTo(memberLabel.snp.bottom).offset(18.scale375Height()) make.leading.equalToSuperview().offset(16.scale375()) make.trailing.equalToSuperview().offset(-16.scale375()) make.height.equalTo(36.scale375Height()) } if #available(iOS 13.0, *) { searchBar.searchTextField.snp.makeConstraints { make in make.edges.equalToSuperview() } } else { if let searchField = searchBar.value(forKey: "searchField") as? UITextField { searchField.snp.makeConstraints { make in make.edges.equalToSuperview() } } } listStateView.snp.makeConstraints { make in make.top.equalTo(searchBar.snp.bottom).offset(15.scale375Height()) make.leading.trailing.equalTo(searchBar) make.height.equalTo(36.scale375Height()) } setupListStateView() bottomControlView.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(84.scale375Height()) } callEveryoneButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16.scale375()) make.trailing.equalToSuperview().offset(-16.scale375()) make.bottom.equalToSuperview().offset(-34.scale375()) make.height.equalTo(40.scale375Height()) } raiseHandNotificationView.snp.makeConstraints { make in make.top.equalTo(listStateView.snp.bottom).offset(10.scale375Height()) make.leading.equalToSuperview().offset(8.scale375()) make.trailing.equalToSuperview().offset(-8.scale375()) make.height.equalTo(40.scale375Height()) } setupUserListTableView() muteAllAudioButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(10.scale375Height()) make.leading.equalToSuperview().offset(16.scale375()) make.width.equalTo(108.scale375()) make.height.equalTo(40.scale375()) } muteAllVideoButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(10.scale375Height()) make.leading.equalToSuperview().offset(133.scale375()) make.width.equalTo(108.scale375()) make.height.equalTo(40.scale375()) } moreFunctionButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(10.scale375Height()) make.leading.equalToSuperview().offset(250.scale375()) make.width.equalTo(108.scale375()) make.height.equalTo(40.scale375()) } searchControl.snp.makeConstraints { make in make.edges.equalToSuperview() } } private func reportViewShow() { viewModel.reportUserListPanelShow() } private func setupUserListTableView() { guard userListTableView.superview != nil else { return } userListTableView.snp.remakeConstraints { make in let aboveView = viewModel.isShownNotificationView ? raiseHandNotificationView : listStateView let bottomView = viewModel.userListType == .notInRoomUsers ? callEveryoneButton : bottomControlView make.top.equalTo(aboveView.snp.bottom).offset(15.scale375Height()) make.leading.equalToSuperview().offset(16.scale375()) make.trailing.equalToSuperview().offset(-16.scale375()) make.bottom.equalTo(bottomView.snp.top) } } private func setupListStateView() { if viewModel.isSeatEnabled { onStageButton.snp.remakeConstraints { make in make.left.centerY.equalToSuperview() make.height.equalTo(32.scale375Height()) make.width.equalToSuperview().multipliedBy(1.0 / 3.0) } offStageButton.snp.remakeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(onStageButton.snp.trailing) make.height.equalTo(32.scale375Height()) make.width.equalToSuperview().multipliedBy(1.0 / 3.0) } invatationListButton.snp.remakeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(offStageButton.snp.trailing) make.height.equalTo(32.scale375Height()) make.width.equalToSuperview().multipliedBy(1.0 / 3.0) } } else { haveEnteredButton.snp.remakeConstraints { make in make.left.centerY.equalToSuperview() make.height.equalTo(32.scale375Height()) make.width.equalToSuperview().multipliedBy(0.5) } invatationListButton.snp.remakeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(haveEnteredButton.snp.trailing) make.height.equalTo(32.scale375Height()) make.width.equalToSuperview().multipliedBy(0.5) } } } func bindInteraction() { viewModel.viewResponder = self setupViewState() searchBar.delegate = self muteAllAudioButton.addTarget(self, action: #selector(muteAllAudioAction), for: .touchUpInside) muteAllVideoButton.addTarget(self, action: #selector(muteAllVideoAction), for: .touchUpInside) moreFunctionButton.addTarget(self, action: #selector(moreFunctionAction), for: .touchUpInside) let tap = UITapGestureRecognizer(target: self, action: #selector(hideSearchControl(sender:))) searchControl.addGestureRecognizer(tap) haveEnteredButton.addTarget(self, action: #selector(selectAllUserAction(sender:)), for: .touchUpInside) onStageButton.addTarget(self, action: #selector(selectOnStageAction(sender:)), for: .touchUpInside) offStageButton.addTarget(self, action: #selector(selectOffStageAction(sender:)), for: .touchUpInside) invatationListButton.addTarget(self, action: #selector(selectInvitationListAction(sender:)), for: .touchUpInside) callEveryoneButton.addTarget(self, action: #selector(callEveryoneAction(sender:)), for: .touchUpInside) invitationListPublisher .receive(on: DispatchQueue.mainQueue) .sink { [weak self] invitationList in guard let self = self else { return } let oldList = viewModel.invitationList viewModel.invitationList = invitationList if viewModel.userListType == .notInRoomUsers { self.updateInvitationTableView(oldList: oldList, newList: invitationList) self.updateBottomControlView() } self.updateListStateView() } .store(in: &cancellableSet) } func setupViewState() { memberLabel.text = String(format: .memberText, viewModel.allUserCount) let roomInfo = viewModel.roomInfo muteAllAudioButton.isSelected = roomInfo.isMicrophoneDisableForAllUser muteAllVideoButton.isSelected = roomInfo.isCameraDisableForAllUser bottomControlView.isHidden = !viewModel.isShownBottomControlView setupListStateViewText() } private func setupListStateViewText() { if viewModel.isSeatEnabled { let seatedListText: String = localizedReplace(.onStageNumberText, replace: String(viewModel.onStageCount)) onStageButton.setTitle(seatedListText, for: .normal) let offSeatListText: String = localizedReplace(.notOnStageNumberText, replace: String(viewModel.offStageCount)) offStageButton.setTitle(offSeatListText, for: .normal) } else { let haveEnteredListText: String = localizedReplace(.haveEnterenRoomText, replace: String(viewModel.allUserCount)) haveEnteredButton.setTitle(haveEnteredListText, for: .normal) } let invitationListText: String = localizedReplace(.notEnteredRoomText, replace: String(viewModel.invitationUserList.count)) invatationListButton.setTitle(invitationListText, for: .normal) } private func updateBottomControlView() { if viewModel.userListType == .notInRoomUsers { bottomControlView.isHidden = true callEveryoneButton.isHidden = viewModel.invitationList.isEmpty } else { callEveryoneButton.isHidden = true bottomControlView.isHidden = !viewModel.isShownBottomControlView } setupUserListTableView() } @objc func muteAllAudioAction(sender: UIButton) { viewModel.muteAllAudioAction(sender: sender, view: self) } @objc func muteAllVideoAction(sender: UIButton) { viewModel.muteAllVideoAction(sender: sender, view: self) } @objc func moreFunctionAction(sender: UIButton) { let view = MoreUserManagerView() view.show(rootView: self) } @objc func hideSearchControl(sender: UIView) { if #available(iOS 13, *) { searchBar.searchTextField.resignFirstResponder() } else { searchBar.resignFirstResponder() } searchControl.isHidden = true } @objc func selectAllUserAction(sender: UIButton) { guard sender.isSelected != true else { return } sender.isSelected = true invatationListButton.isSelected = false viewModel.changeListState(type: .allUsers) updateBottomControlView() } @objc func selectOnStageAction(sender: UIButton) { guard sender.isSelected != true else { return } sender.isSelected = true offStageButton.isSelected = false invatationListButton.isSelected = false viewModel.changeListState(type: .onStageUsers) updateBottomControlView() } @objc func selectOffStageAction(sender: UIButton) { guard sender.isSelected != true else { return } sender.isSelected = true onStageButton.isSelected = false invatationListButton.isSelected = false viewModel.changeListState(type: .offStageUsers) updateBottomControlView() } @objc func selectInvitationListAction(sender: UIButton) { guard sender.isSelected != true else { return } sender.isSelected = true haveEnteredButton.isSelected = false onStageButton.isSelected = false offStageButton.isSelected = false viewModel.changeListState(type: .notInRoomUsers) updateBottomControlView() } @objc func callEveryoneAction(sender: UIButton) { let userIdsNeedtoCall = viewModel.invitationList .filter { $0.status != .pending } .map { $0.invitee.userId } conferenceStore.dispatch(action: ConferenceInvitationActions.inviteUsers(payload: (viewModel.roomInfo.roomId, userIdsNeedtoCall))) } func updateInvitationTableView(oldList: [TUIInvitation], newList: [TUIInvitation]) { let result = viewModel.compareLists(oldList: oldList, newList: newList) userListTableView.beginUpdates() for invitation in result.removed { if let index = oldList.firstIndex(where: { $0.invitee.userId == invitation.invitee.userId }) { userListTableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) viewModel.attendeeList.remove(at: index) } } for invitation in result.added { viewModel.attendeeList.insert(UserEntity(invitation: invitation), at: 0) userListTableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic) } for invitation in result.changed { if let index = oldList.firstIndex(where: { $0.invitee.userId == invitation.invitee.userId }) { if invitation.status == .rejected { DispatchQueue.main.async { if let cell = self.userListTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? UserListCell { cell.showNotJoiningLabel() } } } userListTableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none) } } userListTableView.endUpdates() } deinit { debugPrint("deinit \(self)") } @Injected(\.conferenceStore) private var conferenceStore } extension UserListView: UISearchBarDelegate { func searchBar(_ searchBar:UISearchBar,textDidChange searchText:String){ let searchContentText = searchText.trimmingCharacters(in: .whitespaces) viewModel.searchText = searchContentText viewModel.isSearching = searchContentText.count != 0 viewModel.updateAttendeeList() userListTableView.reloadData() } func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { searchControl.isHidden = false return true } func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { viewModel.reportUserListSearch() return true } } extension UserListView: UITableViewDataSource { internal func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return viewModel.attendeeList.count } } extension UserListView: UITableViewDelegate { internal func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let attendeeModel = viewModel.attendeeList[indexPath.row] let cell = UserListCell(attendeeModel: attendeeModel, viewModel: viewModel) cell.selectionStyle = .none return cell } internal func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let attendeeModel = viewModel.attendeeList[indexPath.row] viewModel.showUserManageViewAction(userId: attendeeModel.userId, userName: attendeeModel.userName) } internal func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 60.scale375Height() } } extension UserListView: UserListViewResponder { func updateUserListTableView() { setupUserListTableView() } func updateMemberLabel(count: Int) { memberLabel.text = String(format: .memberText, viewModel.allUserCount) } func updateListStateView() { setupListStateViewText() } func updateMuteAllAudioButtonState(isSelect: Bool) { muteAllAudioButton.isSelected = isSelect } func updateMuteAllVideoButtonState(isSelect: Bool) { muteAllVideoButton.isSelected = isSelect } func updateBottomControlView(isHidden: Bool) { bottomControlView.isHidden = isHidden } func updateUserManagerViewDisplayStatus(isHidden: Bool) { let model = UserListManagerViewModel(selectUserId: viewModel.userId) let view = UserListManagerView(viewModel: model) view.show(rootView: self) } func makeToast(text: String) { RoomRouter.makeToastInCenter(toast: text, duration: 0.5) } func reloadUserListView() { userListTableView.reloadData() } func showAlert(title: String?, message: String?, sureTitle: String?, declineTitle: String?, sureBlock: (() -> ())?, declineBlock: (() -> ())?) { RoomRouter.presentAlert(title: title, message: message, sureTitle: sureTitle, declineTitle: declineTitle, sureBlock: sureBlock, declineBlock: declineBlock) } } private extension String { static var allMuteAudioText: String { localized("Mute All") } static var allMuteVideoText: String { localized("Stop all video") } static var allUnMuteAudioText: String { localized("Unmute all") } static var allUnMuteVideoText: String { localized("Enable all video") } static var moreText: String { localized("More") } static var memberText: String { localized("Users(%lld)") } static var searchMemberText: String { localized("Search for participants") } static var onStageNumberText: String { localized("On stage(xx)") } static var notOnStageNumberText: String { localized("Not on stage(xx)") } static var notEnteredRoomText: String { localized("Not Entered(xx)") } static var haveEnterenRoomText: String { localized("Entered(xx)") } static var callEveryoneText: String { localized("Call all") } }