LNIMChatViewController.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. //
  2. // LNIMChatViewController.swift
  3. // Lanu
  4. //
  5. // Created by OneeChan on 2025/12/4.
  6. //
  7. import Foundation
  8. import UIKit
  9. import SnapKit
  10. import Combine
  11. enum LNIMChatAction {
  12. case locateSkill(id: String)
  13. }
  14. extension UIView {
  15. func pushToChat(uid: String, action: LNIMChatAction? = nil, scene: LNCommonSceneType = .unknown) {
  16. let vc = LNIMChatViewController(userId: uid)
  17. // LNIMManager.shared.saveDraftFor("c2c_" + uid, draft: " ")
  18. // LNIMManager.shared.saveDraftFor("c2c_" + uid, draft: nil)
  19. if scene == .autoReply {
  20. vc.markFromAutoReply()
  21. } else if scene == .visitor {
  22. vc.reportVisitorEnterEvent()
  23. }
  24. vc.handlerAction(action)
  25. navigationController?.pushViewController(vc, animated: true)
  26. }
  27. }
  28. class LNIMChatViewController: LNViewController {
  29. private let viewModel: LNIMChatViewModel
  30. private let infoStackView = UIStackView()
  31. private let unreadLabel = UILabel()
  32. private let avatar = UIImageView()
  33. private let nameLabel = UILabel()
  34. private let stateLabel = UILabel()
  35. private let followButton = UIButton()
  36. private let menuStackView = UIStackView()
  37. private let phone = UIButton()
  38. private let stackView = UIStackView()
  39. private let skillView = LNIMChatGameMateSkillView()
  40. private let orderView = LNIMChatGameMateOrderView()
  41. private let tableView = UITableView()
  42. private let bottomMenu = LNIMChatInputMenuView()
  43. init(userId: String) {
  44. viewModel = LNIMChatViewModel(userId: userId)
  45. super.init(nibName: nil, bundle: nil)
  46. }
  47. required init?(coder: NSCoder) {
  48. fatalError("init(coder:) has not been implemented")
  49. }
  50. override func viewDidLoad() {
  51. super.viewDidLoad()
  52. setupViews()
  53. LNEventDeliver.addObserver(self)
  54. loadMessageList()
  55. updateUserInfo()
  56. LNStatisticManager.shared.reportViewChat(uid: viewModel.userId)
  57. }
  58. func markFromAutoReply() {
  59. viewModel.needMarkAutoReply = true
  60. }
  61. func reportVisitorEnterEvent() {
  62. let event = LNReportEvent()
  63. event.event = .playmate_proactive_chat
  64. event.scene = .playmate_proactive_chat_report
  65. event.uid = viewModel.userId
  66. LNReportManager.shared.reportEvent(event: event)
  67. }
  68. func handlerAction(_ action: LNIMChatAction?) {
  69. guard let action else { return }
  70. switch action {
  71. case .locateSkill(let id):
  72. skillView.defaultId = id
  73. }
  74. }
  75. override func viewWillDisappear(_ animated: Bool) {
  76. super.viewWillDisappear(animated)
  77. view.endEditing(true)
  78. viewModel.cleanUnread()
  79. }
  80. }
  81. extension LNIMChatViewController {
  82. private func loadMessageList() {
  83. viewModel.loadNextPage { [weak self] success, isFirst in
  84. guard let self else { return }
  85. guard success else { return }
  86. let oldHeight = tableView.contentSize.height
  87. tableView.reloadData()
  88. if isFirst {
  89. tableView.scrollToBottom(animated: false)
  90. } else {
  91. tableView.layoutIfNeeded()
  92. let newOffset = tableView.contentSize.height - oldHeight
  93. tableView.scrollRectToVisible(
  94. .init(x: 0, y: newOffset,
  95. width: tableView.bounds.width,
  96. height: tableView.bounds.height),
  97. animated: false)
  98. }
  99. }
  100. }
  101. private func updateUserInfo() {
  102. loadRelation()
  103. loadUnreadCount()
  104. loadUserOnlineStatus()
  105. viewModel.$userInfo.sink { [weak self] newInfo in
  106. guard let self else { return }
  107. guard let newInfo else { return }
  108. nameLabel.text = viewModel.remark?.isEmpty == false ? viewModel.remark : newInfo.nickname
  109. avatar.showAvatar(newInfo.avatar)
  110. phone.isHidden = !myUserInfo.playmate && !newInfo.playmate
  111. }.store(in: &cancellables)
  112. viewModel.$remark.sink { [weak self] newValue in
  113. guard let self else { return }
  114. guard let newValue else { return }
  115. nameLabel.text = !newValue.isEmpty ? newValue : viewModel.userInfo?.nickname
  116. }.store(in: &cancellables)
  117. }
  118. private func loadUnreadCount() {
  119. let unreadCount = LNIMManager.shared.conversationList.filter { $0.userID != viewModel.userId }.reduce(0) { $0 + $1.unreadCount}
  120. unreadLabel.text = "\(unreadCount)"
  121. unreadLabel.isHidden = unreadCount == 0
  122. }
  123. private func loadUserOnlineStatus() {
  124. LNIMManager.shared.getUserOnlineState(uid: viewModel.userId) { [weak self] online in
  125. guard let self else { return }
  126. stateLabel.text = online ? .init(key: "A00089") : .init(key: "A00090")
  127. }
  128. }
  129. private func loadRelation() {
  130. LNRelationManager.shared.getRelationWithUser(uid: viewModel.userId, handler: nil)
  131. }
  132. }
  133. extension LNIMChatViewController: LNIMManagerNotify {
  134. func onConversationListChanged() {
  135. loadUnreadCount()
  136. }
  137. func onIMUserStatusChanged(uid: String, status: V2TIMUserStatusType) {
  138. guard uid == viewModel.userId else { return }
  139. stateLabel.text = status == .USER_STATUS_ONLINE ? .init(key: "A00089") : .init(key: "A00090")
  140. }
  141. }
  142. extension LNIMChatViewController: LNRelationManagerNotify {
  143. func onUserRelationChanged(uid: String, relation: LNUserRelationShip) {
  144. guard uid == viewModel.userId else { return }
  145. followButton.isHidden = relation.contains(.followed)
  146. }
  147. }
  148. extension LNIMChatViewController: LNIMChatViewModelNotify {
  149. func onIMMessageDataChanged(viewModel: LNIMChatViewModel, index: Int, type: LNIMChatDataSourceChangeType, toBottom: Bool) {
  150. guard viewModel.userId == self.viewModel.userId else { return }
  151. switch type {
  152. case .insert: tableView.reloadData()
  153. case .delete: tableView.reloadData()
  154. case .reload: tableView.reloadRows(at: [.init(row: index, section: 0)], with: .fade)
  155. }
  156. if toBottom {
  157. tableView.scrollToBottom()
  158. }
  159. }
  160. func onIMMessageDatasChanged(viewModel: LNIMChatViewModel, indexs: [IndexPath], type: LNIMChatDataSourceChangeType, toBottom: Bool) {
  161. guard viewModel.userId == self.viewModel.userId else { return }
  162. switch type {
  163. case .insert: tableView.reloadData()
  164. case .delete: tableView.reloadData()
  165. case .reload: tableView.reloadRows(at: indexs, with: .fade)
  166. }
  167. if toBottom {
  168. tableView.scrollToBottom()
  169. }
  170. }
  171. }
  172. extension LNIMChatViewController: UITableViewDataSource, UITableViewDelegate {
  173. func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  174. viewModel.allMessage.count
  175. }
  176. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  177. let data = viewModel.allMessage[indexPath.row]
  178. if !data.imMessage.isSelf, data.imMessage.needReadReceipt,
  179. !data.imMessage.isRead {
  180. V2TIMManager.sharedInstance().sendMessageReadReceipts(messageList: [data.imMessage]) { }
  181. fail: { code, err in }
  182. }
  183. switch data.type {
  184. case .autoReply(let type):
  185. switch type {
  186. case .text:
  187. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatTextMessageCell.className, for: indexPath) as! LNIMChatTextMessageCell
  188. cell.update(data, viewModel: viewModel)
  189. return cell
  190. case .voice:
  191. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell
  192. cell.update(data, viewModel: viewModel)
  193. return cell
  194. }
  195. case .system:
  196. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatSystemMessageCell.className, for: indexPath) as! LNIMChatSystemMessageCell
  197. cell.update(data)
  198. return cell
  199. case .image:
  200. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatImageMessageCell.className, for: indexPath) as! LNIMChatImageMessageCell
  201. cell.update(data, viewModel: viewModel)
  202. return cell
  203. case .text:
  204. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatTextMessageCell.className, for: indexPath) as! LNIMChatTextMessageCell
  205. cell.update(data, viewModel: viewModel)
  206. return cell
  207. case .voice:
  208. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell
  209. cell.update(data, viewModel: viewModel)
  210. return cell
  211. case .order:
  212. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatOrderMessageCell.className, for: indexPath) as! LNIMChatOrderMessageCell
  213. cell.update(data, viewModel: viewModel)
  214. return cell
  215. case .call:
  216. let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatCallMessageCell.className, for: indexPath) as! LNIMChatCallMessageCell
  217. cell.update(data, viewModel: viewModel)
  218. return cell
  219. case .none, .official:
  220. break
  221. }
  222. return tableView.dequeueReusableCell(withIdentifier: LNIMChatUnknownMessageCell.className, for: indexPath)
  223. }
  224. func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
  225. guard scrollView == tableView else { return }
  226. if tableView.contentOffset.y <= 0 {
  227. loadMessageList()
  228. }
  229. }
  230. func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
  231. guard scrollView == tableView else { return }
  232. if tableView.contentOffset.y <= 0 {
  233. loadMessageList()
  234. }
  235. }
  236. }
  237. extension LNIMChatViewController: LNKeyboardNotify {
  238. func onKeyboardWillShow(curInput: UIView?, keyboardHeight: CGFloat) {
  239. guard curInput?.isDescendant(of: view) == true else { return }
  240. tableView.scrollToBottom(animated: false)
  241. }
  242. func onKeyboardShow(curInput: UIView?, keyboardHeight: CGFloat) {
  243. guard curInput?.isDescendant(of: view) == true else { return }
  244. tableView.scrollToBottom(animated: true)
  245. }
  246. }
  247. extension LNIMChatViewController {
  248. private func setupViews() {
  249. view.backgroundColor = .primary_1
  250. setupNavBar()
  251. stackView.axis = .vertical
  252. stackView.spacing = 8
  253. view.addSubview(stackView)
  254. stackView.snp.makeConstraints { make in
  255. make.horizontalEdges.equalToSuperview()
  256. make.top.equalToSuperview()
  257. }
  258. stackView.addArrangedSubview(skillView)
  259. skillView.snp.makeConstraints { make in
  260. make.width.equalToSuperview()
  261. } // 需要强制宽度,否则会在刷新时,宽度多次变化
  262. stackView.addArrangedSubview(orderView)
  263. stackView.layoutIfNeeded()
  264. skillView.viewModel = viewModel
  265. orderView.viewModel = viewModel
  266. bottomMenu.viewModel = viewModel
  267. view.addSubview(bottomMenu)
  268. bottomMenu.snp.makeConstraints { make in
  269. make.horizontalEdges.equalToSuperview()
  270. make.bottom.equalToSuperview()
  271. }
  272. tableView.backgroundColor = .clear
  273. tableView.separatorStyle = .none
  274. tableView.allowsSelection = false
  275. tableView.register(LNIMChatImageMessageCell.self, forCellReuseIdentifier: LNIMChatImageMessageCell.className)
  276. tableView.register(LNIMChatSystemMessageCell.self, forCellReuseIdentifier: LNIMChatSystemMessageCell.className)
  277. tableView.register(LNIMChatTextMessageCell.self, forCellReuseIdentifier: LNIMChatTextMessageCell.className)
  278. tableView.register(LNIMChatVoiceMessageCell.self, forCellReuseIdentifier: LNIMChatVoiceMessageCell.className)
  279. tableView.register(LNIMChatUnknownMessageCell.self, forCellReuseIdentifier: LNIMChatUnknownMessageCell.className)
  280. tableView.register(LNIMChatOrderMessageCell.self, forCellReuseIdentifier: LNIMChatOrderMessageCell.className)
  281. tableView.register(LNIMChatCallMessageCell.self, forCellReuseIdentifier: LNIMChatCallMessageCell.className)
  282. tableView.dataSource = self
  283. tableView.delegate = self
  284. tableView.contentInset = .init(top: 8, left: 0, bottom: 0, right: 0)
  285. view.addSubview(tableView)
  286. tableView.snp.makeConstraints { make in
  287. make.horizontalEdges.equalToSuperview()
  288. make.top.equalTo(stackView.snp.bottom)
  289. make.bottom.equalTo(bottomMenu.snp.top)
  290. }
  291. tableView.onTap { [weak self] in
  292. guard let self else { return }
  293. bottomMenu.hideInput()
  294. }
  295. }
  296. private func setupNavBar() {
  297. infoStackView.axis = .horizontal
  298. infoStackView.spacing = 12
  299. infoStackView.alignment = .center
  300. infoStackView.onTap { [weak self] in
  301. guard let self else { return }
  302. view.pushToProfile(uid: viewModel.userId)
  303. }
  304. setTitleView(infoStackView)
  305. infoStackView.snp.makeConstraints { make in
  306. make.width.equalTo(view.bounds.width).priority(.medium)
  307. make.height.equalTo(44)
  308. }
  309. unreadLabel.textColor = .text_6
  310. unreadLabel.font = .body_l
  311. unreadLabel.setContentHuggingPriority(.required, for: .horizontal)
  312. unreadLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  313. infoStackView.addArrangedSubview(unreadLabel)
  314. avatar.layer.cornerRadius = 17
  315. avatar.clipsToBounds = true
  316. avatar.snp.makeConstraints { make in
  317. make.width.height.equalTo(34)
  318. }
  319. infoStackView.addArrangedSubview(avatar)
  320. let textView = UIView()
  321. infoStackView.addArrangedSubview(textView)
  322. nameLabel.font = .heading_h3
  323. nameLabel.textColor = .text_5
  324. nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  325. nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  326. textView.addSubview(nameLabel)
  327. nameLabel.snp.makeConstraints { make in
  328. make.leading.top.equalToSuperview()
  329. make.trailing.equalToSuperview()
  330. }
  331. stateLabel.font = .body_xs
  332. stateLabel.textColor = .text_3
  333. textView.addSubview(stateLabel)
  334. stateLabel.snp.makeConstraints { make in
  335. make.leading.trailing.equalToSuperview()
  336. make.bottom.equalToSuperview()
  337. make.top.equalTo(nameLabel.snp.bottom)
  338. }
  339. menuStackView.axis = .horizontal
  340. menuStackView.spacing = 16
  341. setRightButton(menuStackView)
  342. followButton.setImage(.icImChatFollow, for: .normal)
  343. followButton.addAction(UIAction(handler: { [weak self] _ in
  344. guard let self else { return }
  345. LNRelationManager.shared.operateFollow(uid: viewModel.userId, follow: true, handler: nil)
  346. }), for: .touchUpInside)
  347. menuStackView.addArrangedSubview(followButton)
  348. if LNIMManager.shared.voiceCallAvailable {
  349. phone.setImage(.icImChatPhone, for: .normal)
  350. phone.isHidden = true
  351. phone.addAction(UIAction(handler: { [weak self] _ in
  352. guard let self else { return }
  353. guard !viewModel.userId.isEmpty else { return }
  354. if viewModel.myOrders.first(where: { $0.status == .servicing }) != nil {
  355. LNIMManager.shared.makeVoiceCall(uid: viewModel.userId)
  356. } else if let userInfo = viewModel.userInfo {
  357. if !userInfo.playmate {
  358. showToast(.init(key: "A00292"))
  359. } else if viewModel.myOrders.first(where: { $0.status == .accepted || $0.status == .waitingForAccept }) != nil {
  360. showToast(.init(key: "A00293"))
  361. } else {
  362. let panel = LNCreateOrderFromSkillListPanel()
  363. panel.update(userInfo.skills, selected: skillView.curSkill)
  364. panel.popup()
  365. }
  366. }
  367. }), for: .touchUpInside)
  368. menuStackView.addArrangedSubview(phone)
  369. }
  370. let more = UIButton()
  371. more.setImage(.icImChatMore, for: .normal)
  372. more.addAction(UIAction(handler: { [weak self] _ in
  373. guard let self else { return }
  374. let panel = LNIMChatUserMenuView()
  375. panel.viewModel = viewModel
  376. panel.popup()
  377. }), for: .touchUpInside)
  378. menuStackView.addArrangedSubview(more)
  379. }
  380. }