// // LNIMChatViewController.swift // Lanu // // Created by OneeChan on 2025/12/4. // import Foundation import UIKit import SnapKit import Combine enum LNIMChatAction { case locateSkill(id: String) } extension UIView { func pushToChat(uid: String, action: LNIMChatAction? = nil, scene: LNCommonSceneType = .unknown) { let vc = LNIMChatViewController(userId: uid) // LNIMManager.shared.saveDraftFor("c2c_" + uid, draft: " ") // LNIMManager.shared.saveDraftFor("c2c_" + uid, draft: nil) if scene == .autoReply { vc.markFromAutoReply() } else if scene == .visitor { vc.reportVisitorEnterEvent() } vc.handlerAction(action) navigationController?.pushViewController(vc, animated: true) } } class LNIMChatViewController: LNViewController { private let viewModel: LNIMChatViewModel private let infoStackView = UIStackView() private let unreadLabel = UILabel() private let avatar = UIImageView() private let nameLabel = UILabel() private let stateLabel = UILabel() private let followButton = UIButton() private let menuStackView = UIStackView() private let phone = UIButton() private let stackView = UIStackView() private let skillView = LNIMChatGameMateSkillView() private let orderView = LNIMChatGameMateOrderView() private let tableView = UITableView() private let bottomMenu = LNIMChatInputMenuView() init(userId: String) { viewModel = LNIMChatViewModel(userId: userId) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setupViews() LNEventDeliver.addObserver(self) loadMessageList() updateUserInfo() LNStatisticManager.shared.reportViewChat(uid: viewModel.userId) } func markFromAutoReply() { viewModel.needMarkAutoReply = true } func reportVisitorEnterEvent() { let event = LNReportEvent() event.event = .playmate_proactive_chat event.scene = .playmate_proactive_chat_report event.uid = viewModel.userId LNReportManager.shared.reportEvent(event: event) } func handlerAction(_ action: LNIMChatAction?) { guard let action else { return } switch action { case .locateSkill(let id): skillView.defaultId = id } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) view.endEditing(true) viewModel.cleanUnread() } } extension LNIMChatViewController { private func loadMessageList() { viewModel.loadNextPage { [weak self] success, isFirst in guard let self else { return } guard success else { return } let oldHeight = tableView.contentSize.height tableView.reloadData() if isFirst { tableView.scrollToBottom(animated: false) } else { tableView.layoutIfNeeded() let newOffset = tableView.contentSize.height - oldHeight tableView.scrollRectToVisible( .init(x: 0, y: newOffset, width: tableView.bounds.width, height: tableView.bounds.height), animated: false) } } } private func updateUserInfo() { loadRelation() loadUnreadCount() loadUserOnlineStatus() viewModel.$userInfo.sink { [weak self] newInfo in guard let self else { return } guard let newInfo else { return } nameLabel.text = viewModel.remark?.isEmpty == false ? viewModel.remark : newInfo.nickname avatar.showAvatar(newInfo.avatar) phone.isHidden = !myUserInfo.playmate && !newInfo.playmate }.store(in: &cancellables) viewModel.$remark.sink { [weak self] newValue in guard let self else { return } guard let newValue else { return } nameLabel.text = !newValue.isEmpty ? newValue : viewModel.userInfo?.nickname }.store(in: &cancellables) } private func loadUnreadCount() { let unreadCount = LNIMManager.shared.conversationList.filter { $0.userID != viewModel.userId }.reduce(0) { $0 + $1.unreadCount} unreadLabel.text = "\(unreadCount)" unreadLabel.isHidden = unreadCount == 0 } private func loadUserOnlineStatus() { LNIMManager.shared.getUserOnlineState(uid: viewModel.userId) { [weak self] online in guard let self else { return } stateLabel.text = online ? .init(key: "A00089") : .init(key: "A00090") } } private func loadRelation() { LNRelationManager.shared.getRelationWithUser(uid: viewModel.userId, handler: nil) } } extension LNIMChatViewController: LNIMManagerNotify { func onConversationListChanged() { loadUnreadCount() } func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) { guard uid == viewModel.userId else { return } stateLabel.text = status == .USER_STATUS_ONLINE ? .init(key: "A00089") : .init(key: "A00090") } } extension LNIMChatViewController: LNRelationManagerNotify { func onUserRelationChanged(uid: String, relation: LNUserRelationShip) { guard uid == viewModel.userId else { return } followButton.isHidden = relation.contains(.followed) } } extension LNIMChatViewController: LNIMChatViewModelNotify { func onIMMessageDataChanged(viewModel: LNIMChatViewModel, index: Int, type: LNIMChatDataSourceChangeType, toBottom: Bool) { guard viewModel.userId == self.viewModel.userId else { return } switch type { case .insert: tableView.reloadData() case .delete: tableView.reloadData() case .reload: tableView.reloadRows(at: [.init(row: index, section: 0)], with: .fade) } if toBottom { tableView.scrollToBottom() } } func onIMMessageDatasChanged(viewModel: LNIMChatViewModel, indexs: [IndexPath], type: LNIMChatDataSourceChangeType, toBottom: Bool) { guard viewModel.userId == self.viewModel.userId else { return } switch type { case .insert: tableView.reloadData() case .delete: tableView.reloadData() case .reload: tableView.reloadRows(at: indexs, with: .fade) } if toBottom { tableView.scrollToBottom() } } } extension LNIMChatViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewModel.allMessage.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let data = viewModel.allMessage[indexPath.row] if !data.imMessage.isSelf, data.imMessage.needReadReceipt, !data.imMessage.isRead { V2TIMManager.sharedInstance().sendMessageReadReceipts(messageList: [data.imMessage]) { } fail: { code, err in } } switch data.type { case .autoReply(let type): switch type { case .text: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatTextMessageCell.className, for: indexPath) as! LNIMChatTextMessageCell cell.update(data, viewModel: viewModel) return cell case .voice: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell cell.update(data, viewModel: viewModel) return cell } case .system: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatSystemMessageCell.className, for: indexPath) as! LNIMChatSystemMessageCell cell.update(data) return cell case .image: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatImageMessageCell.className, for: indexPath) as! LNIMChatImageMessageCell cell.update(data, viewModel: viewModel) return cell case .text: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatTextMessageCell.className, for: indexPath) as! LNIMChatTextMessageCell cell.update(data, viewModel: viewModel) return cell case .voice: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell cell.update(data, viewModel: viewModel) return cell case .order: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatOrderMessageCell.className, for: indexPath) as! LNIMChatOrderMessageCell cell.update(data, viewModel: viewModel) return cell case .call: let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatCallMessageCell.className, for: indexPath) as! LNIMChatCallMessageCell cell.update(data, viewModel: viewModel) return cell case .none, .official: break } return tableView.dequeueReusableCell(withIdentifier: LNIMChatUnknownMessageCell.className, for: indexPath) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { guard scrollView == tableView else { return } if tableView.contentOffset.y <= 0 { loadMessageList() } } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { guard scrollView == tableView else { return } if tableView.contentOffset.y <= 0 { loadMessageList() } } } extension LNIMChatViewController: LNKeyboardNotify { func onKeyboardWillShow(curInput: UIView?, keyboardHeight: CGFloat) { guard curInput?.isDescendant(of: view) == true else { return } tableView.scrollToBottom(animated: false) } func onKeyboardShow(curInput: UIView?, keyboardHeight: CGFloat) { guard curInput?.isDescendant(of: view) == true else { return } tableView.scrollToBottom(animated: true) } } extension LNIMChatViewController { private func setupViews() { view.backgroundColor = .primary_1 setupNavBar() stackView.axis = .vertical stackView.spacing = 8 view.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } stackView.addArrangedSubview(skillView) skillView.snp.makeConstraints { make in make.width.equalToSuperview() } // 需要强制宽度,否则会在刷新时,宽度多次变化 stackView.addArrangedSubview(orderView) stackView.layoutIfNeeded() skillView.viewModel = viewModel orderView.viewModel = viewModel bottomMenu.viewModel = viewModel view.addSubview(bottomMenu) bottomMenu.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() } tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.allowsSelection = false tableView.register(LNIMChatImageMessageCell.self, forCellReuseIdentifier: LNIMChatImageMessageCell.className) tableView.register(LNIMChatSystemMessageCell.self, forCellReuseIdentifier: LNIMChatSystemMessageCell.className) tableView.register(LNIMChatTextMessageCell.self, forCellReuseIdentifier: LNIMChatTextMessageCell.className) tableView.register(LNIMChatVoiceMessageCell.self, forCellReuseIdentifier: LNIMChatVoiceMessageCell.className) tableView.register(LNIMChatUnknownMessageCell.self, forCellReuseIdentifier: LNIMChatUnknownMessageCell.className) tableView.register(LNIMChatOrderMessageCell.self, forCellReuseIdentifier: LNIMChatOrderMessageCell.className) tableView.register(LNIMChatCallMessageCell.self, forCellReuseIdentifier: LNIMChatCallMessageCell.className) tableView.dataSource = self tableView.delegate = self tableView.contentInset = .init(top: 8, left: 0, bottom: 0, right: 0) view.addSubview(tableView) tableView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(stackView.snp.bottom) make.bottom.equalTo(bottomMenu.snp.top) } tableView.onTap { [weak self] in guard let self else { return } bottomMenu.hideInput() } } private func setupNavBar() { infoStackView.axis = .horizontal infoStackView.spacing = 12 infoStackView.alignment = .center infoStackView.onTap { [weak self] in guard let self else { return } view.pushToProfile(uid: viewModel.userId) } setTitleView(infoStackView) infoStackView.snp.makeConstraints { make in make.width.equalTo(view.bounds.width).priority(.medium) make.height.equalTo(44) } unreadLabel.textColor = .text_6 unreadLabel.font = .body_l unreadLabel.setContentHuggingPriority(.required, for: .horizontal) unreadLabel.setContentCompressionResistancePriority(.required, for: .horizontal) infoStackView.addArrangedSubview(unreadLabel) avatar.layer.cornerRadius = 17 avatar.clipsToBounds = true avatar.snp.makeConstraints { make in make.width.height.equalTo(34) } infoStackView.addArrangedSubview(avatar) let textView = UIView() infoStackView.addArrangedSubview(textView) nameLabel.font = .heading_h3 nameLabel.textColor = .text_5 nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textView.addSubview(nameLabel) nameLabel.snp.makeConstraints { make in make.leading.top.equalToSuperview() make.trailing.equalToSuperview() } stateLabel.font = .body_xs stateLabel.textColor = .text_3 textView.addSubview(stateLabel) stateLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.bottom.equalToSuperview() make.top.equalTo(nameLabel.snp.bottom) } menuStackView.axis = .horizontal menuStackView.spacing = 16 setRightButton(menuStackView) followButton.setImage(.icImChatFollow, for: .normal) followButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } LNRelationManager.shared.operateFollow(uid: viewModel.userId, follow: true, handler: nil) }), for: .touchUpInside) menuStackView.addArrangedSubview(followButton) if LNIMManager.shared.voiceCallAvailable { phone.setImage(.icImChatPhone, for: .normal) phone.isHidden = true phone.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard !viewModel.userId.isEmpty else { return } if viewModel.myOrders.first(where: { $0.status == .servicing }) != nil { LNIMManager.shared.makeVoiceCall(uid: viewModel.userId) } else if let userInfo = viewModel.userInfo { if !userInfo.playmate { showToast(.init(key: "A00292")) } else if viewModel.myOrders.first(where: { $0.status == .accepted || $0.status == .waitingForAccept }) != nil { showToast(.init(key: "A00293")) } else { let panel = LNCreateOrderFromSkillListPanel() panel.update(userInfo.skills, selected: skillView.curSkill) panel.popup() } } }), for: .touchUpInside) menuStackView.addArrangedSubview(phone) } let more = UIButton() more.setImage(.icImChatMore, for: .normal) more.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } let panel = LNIMChatUserMenuView() panel.viewModel = viewModel panel.popup() }), for: .touchUpInside) menuStackView.addArrangedSubview(more) } }