From 38946b59afed40126f16e4928ef31b629a3ddd0d Mon Sep 17 00:00:00 2001 From: linshujie Date: Thu, 16 Jul 2026 15:10:47 +0800 Subject: [PATCH] =?UTF-8?q?-=20IM=E6=B6=88=E6=81=AF=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E3=80=81=E6=92=A4=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Section/Group/GroupChat/GroupChatVC.swift | 354 +++++++++- .../Group/GroupChat/GroupChatView.swift | 585 ++++++++++++++--- .../Group/GroupChat/GroupChatViewModel.swift | 616 ++++++++++++++---- 3 files changed, 1332 insertions(+), 223 deletions(-) diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index e5238807..987bfeb2 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -14,6 +14,7 @@ import AVFoundation import AudioToolbox import HXPHPicker import IQKeyboardManagerSwift +import MJRefresh final class GroupChatVC: BaseViewController { @@ -22,6 +23,11 @@ final class GroupChatVC: BaseViewController { fileprivate var rootView: GroupChatView! private let viewModel = GroupChatViewModel() private var msgListener: MessageListenerProxy? + /// mention 列表:首行 @所有人 + 成员 + private var mentionRows: [(userId: String?, nickname: String)] = [] + private var isMentionPickerVisible = false + /// 加载更早历史前记录,用于保持阅读位置 + private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? // MARK: - Init init(groupId: String) { @@ -46,14 +52,17 @@ final class GroupChatVC: BaseViewController { setupMessageListener() setupVoiceRecording() setupPanelDismiss() - + setupChatLongPress() + setupMentionInput() + setupHistoryRefreshHeader() + // 并行加载:业务接口 + IM SDK 互不依赖,同时发起 requestGroupInfoByKey() GroupIMService.shared.ensureLogin { [weak self] success in guard let self = self, success else { return } self.viewModel.loadMessages() } - + // 处理系统配置 guard let config = AppContextManager.shared.systemConfig else { return } rootView.chatWarningView.isHidden = config.chatWarning.isEmpty @@ -139,7 +148,33 @@ final class GroupChatVC: BaseViewController { .skip(1) .observe(on: MainScheduler.asyncInstance) .subscribe(onNext: { [weak self] _ in - self?.scrollToBottom() + guard let self = self else { return } + if self.viewModel.suppressAutoScroll { + self.viewModel.suppressAutoScroll = false + self.restoreOffsetAfterPrependIfNeeded() + return + } + self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory + self.scrollToBottom() + }) + .disposed(by: disposeBag) + + viewModel.output.pendingQuoteSummary + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] summary in + guard let self = self else { return } + if let summary { + self.rootView.setQuoteBarVisible(true, summary: summary) + self.bringInputOverlaysToFront() + } else { + self.rootView.setQuoteBarVisible(false) + } + }) + .disposed(by: disposeBag) + + rootView.quoteBarCloseBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.viewModel.clearPendingQuote() }) .disposed(by: disposeBag) @@ -149,7 +184,58 @@ final class GroupChatVC: BaseViewController { .disposed(by: disposeBag) } - private var hasScrolledToBottom = false + private func bringInputOverlaysToFront() { + rootView.bringSubviewToFront(rootView.mentionPickerView) + rootView.bringSubviewToFront(rootView.quoteBar) + rootView.bringSubviewToFront(rootView.bottomBar) + rootView.bringSubviewToFront(rootView.disableIMView) + } + + // MARK: - 顶部加载更早历史 + private func setupHistoryRefreshHeader() { + let header = MJRefreshNormalHeader { [weak self] in + self?.loadOlderHistoryFromHeader() + } + header.addFeedbackGenerator() + header.lastUpdatedTimeLabel?.isHidden = true + header.setTitle("下拉加载更早消息", for: .idle) + header.setTitle("松开加载", for: .pulling) + header.setTitle("加载中...", for: .refreshing) + rootView.tableView.mj_header = header + } + + private func loadOlderHistoryFromHeader() { + guard viewModel.hasMoreHistory else { + rootView.tableView.mj_header?.endRefreshing() + rootView.tableView.mj_header?.isHidden = true + return + } + let tableView = rootView.tableView + pendingOffsetRestore = (tableView.contentSize.height, tableView.contentOffset.y) + viewModel.loadOlderHistory { [weak self] result in + guard let self = self else { return } + self.rootView.tableView.mj_header?.endRefreshing() + if !result.hasMore { + self.rootView.tableView.mj_header?.isHidden = true + } + // suppressAutoScroll 路径里会 restoreOffset;若未 prepend 也清掉 pending + if result.prependedCount == 0 { + self.pendingOffsetRestore = nil + } + } + } + + private func restoreOffsetAfterPrependIfNeeded() { + guard let pending = pendingOffsetRestore else { return } + pendingOffsetRestore = nil + let tableView = rootView.tableView + tableView.layoutIfNeeded() + let delta = tableView.contentSize.height - pending.oldHeight + guard delta > 0 else { return } + var offset = tableView.contentOffset + offset.y = max(0, pending.oldOffset + delta) + tableView.setContentOffset(offset, animated: false) + } private func scrollToBottom() { let count = dataSource.sectionModels.first?.items.count ?? 0 @@ -161,6 +247,31 @@ final class GroupChatVC: BaseViewController { } } + private func scrollToMessage(clientMsgID: String) { + viewModel.ensureMessageVisible(clientMsgID: clientMsgID) { [weak self] found in + guard let self = self else { return } + guard found, let indexPath = self.viewModel.indexPath(forClientMsgID: clientMsgID) else { + DLToast.showError(text: "无法定位到原消息") + return + } + self.rootView.tableView.layoutIfNeeded() + self.rootView.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextSendMsgCell { + cell.flashHighlight() + } else if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextReceivedMsgCell { + cell.flashHighlight() + } else if let cell = self.rootView.tableView.cellForRow(at: indexPath) { + let old = cell.contentView.backgroundColor + cell.contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) + UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { + cell.contentView.backgroundColor = old + } + } + } + } + } + private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource> = { RxCollectionViewSectionedReloadDataSource> { _, collectionView, indexPath, name in let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath) @@ -171,13 +282,157 @@ final class GroupChatVC: BaseViewController { // MARK: - Message Listener private func setupMessageListener() { - msgListener = MessageListenerProxy { [weak self] msg in - guard let self = self, msg.groupID == self.viewModel.groupId else { return } - self.viewModel.onReceiveMessage(msg) - } + msgListener = MessageListenerProxy( + onMessage: { [weak self] msg in + guard let self = self, msg.groupID == self.viewModel.groupId else { return } + self.viewModel.onReceiveMessage(msg) + }, + onRevoked: { [weak self] info in + self?.viewModel.onMessageRevoked(info) + } + ) OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!) } + // MARK: - Long press (quote / revoke / avatar @) + private func setupChatLongPress() { + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleChatLongPress(_:))) + longPress.minimumPressDuration = 0.45 + rootView.tableView.addGestureRecognizer(longPress) + } + + @objc private func handleChatLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + let point = gesture.location(in: rootView.tableView) + guard let indexPath = rootView.tableView.indexPathForRow(at: point), + let cell = rootView.tableView.cellForRow(at: indexPath) else { return } + let items = viewModel.currentMessages + guard indexPath.row < items.count, + let msg = items[indexPath.row].chatMessage else { return } + + // 长按对方头像 → @ + if !msg.isSelf, let avatar = avatarView(in: cell) { + let p = gesture.location(in: avatar) + if avatar.bounds.insetBy(dx: -6, dy: -6).contains(p) { + insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false) + return + } + } + + showBubbleActionMenu(for: msg, from: cell) + } + + private func avatarView(in cell: UITableViewCell) -> UIImageView? { + if let c = cell as? TextReceivedMsgCell { return c.avatarView } + if let c = cell as? EmojiReceivedMsgCell { return c.avatarView } + if let c = cell as? VoiceReceivedMsgCell { return c.avatarView } + if let c = cell as? ImageReceivedMsgCell { return c.avatarView } + return nil + } + + private func menuAnchorView(in cell: UITableViewCell) -> UIView { + if let c = cell as? TextSendMsgCell { return c.menuAnchorView } + if let c = cell as? TextReceivedMsgCell { return c.menuAnchorView } + if let c = cell as? EmojiSendMsgCell { return c.menuAnchorView } + if let c = cell as? EmojiReceivedMsgCell { return c.menuAnchorView } + if let c = cell as? VoiceSendMsgCell { return c.menuAnchorView } + if let c = cell as? VoiceReceivedMsgCell { return c.menuAnchorView } + if let c = cell as? ImageSendMsgCell { return c.menuAnchorView } + if let c = cell as? ImageReceivedMsgCell { return c.menuAnchorView } + return cell.contentView + } + + private weak var bubbleMenu: ChatBubbleActionMenu? + + private func showBubbleActionMenu(for msg: ChatMessage, from cell: UITableViewCell) { + bubbleMenu?.dismiss() + rootView.dismissAllPanels() + + let menu = ChatBubbleActionMenu() + bubbleMenu = menu + menu.onQuote = { [weak self] in + guard let self = self, + let oim = self.viewModel.oimMessage(forClientMsgID: msg.id) else { + DLToast.showError(text: "无法引用该消息") + return + } + self.viewModel.setPendingQuote(oim) + self.rootView.textField.becomeFirstResponder() + } + menu.onRevoke = { [weak self] in + self?.viewModel.revokeMessage(clientMsgID: msg.id) + } + let anchor = menuAnchorView(in: cell) +// menu.show(in: rootView, anchor: anchor, showRevoke: msg.isSelf) + } + + // MARK: - Mention + private func setupMentionInput() { + rootView.textField.delegate = self + rootView.mentionTableView.delegate = self + rootView.mentionTableView.dataSource = self + rootView.textField.rx.controlEvent(.editingChanged) + .subscribe(onNext: { [weak self] in + self?.handleMentionTrigger() + }) + .disposed(by: disposeBag) + } + + private func handleMentionTrigger() { + guard let text = rootView.textField.text, + let selected = rootView.textField.selectedTextRange else { + hideMentionPicker() + return + } + let cursor = rootView.textField.offset(from: rootView.textField.beginningOfDocument, to: selected.start) + let ns = text as NSString + let prefix = ns.substring(to: min(cursor, ns.length)) + if prefix.hasSuffix("@") { + showMentionPicker() + } else if isMentionPickerVisible { + // 继续输入非空格时保持;遇到空格关闭 + if prefix.last == " " || !prefix.contains("@") { + hideMentionPicker() + } + } + } + + private func showMentionPicker() { + mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.nick_name) } + rootView.mentionTableView.reloadData() + rootView.mentionPickerView.isHidden = false + isMentionPickerVisible = true + bringInputOverlaysToFront() + rootView.layoutIfNeeded() + let bounds = rootView.mentionPickerView.bounds + rootView.mentionPickerView.layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath + } + + private func hideMentionPicker() { + rootView.mentionPickerView.isHidden = true + isMentionPickerVisible = false + } + + private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) { + hideMentionPicker() + let insert: String + if let userId { + insert = viewModel.mentionUser(userId: userId, nickname: nickname) + } else { + viewModel.setPendingAtAll(true) + insert = "@所有人 " + } + var current = rootView.textField.text ?? "" + if replaceTriggerAt, let atIdx = current.lastIndex(of: "@") { + let after = current[current.index(after: atIdx)...] + if after.isEmpty || !after.contains(where: { $0.isWhitespace }) { + current = String(current[.. = { - RxTableViewSectionedReloadDataSource { _, tableView, indexPath, item in + RxTableViewSectionedReloadDataSource { [weak self] _, tableView, indexPath, item in switch item { case let .send(msg): let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(msg) + cell.onQuoteTap = { [weak self] in + guard let id = msg.quotePreview?.quotedClientMsgID else { return } + self?.scrollToMessage(clientMsgID: id) + } return cell case let .received(msg): let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(msg) + cell.onQuoteTap = { [weak self] in + guard let id = msg.quotePreview?.quotedClientMsgID else { return } + self?.scrollToMessage(clientMsgID: id) + } return cell case let .emojiSend(msg): let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -496,7 +759,8 @@ final class GroupChatVC: BaseViewController { self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } return cell - case let .notification(text, showTime, timestamp): + case let .notification(text, showTime, timestamp), + let .revoked(text, showTime, timestamp): let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(text, showTime: showTime, timestamp: timestamp) return cell @@ -505,6 +769,33 @@ final class GroupChatVC: BaseViewController { }() } +// MARK: - UITextFieldDelegate / Mention table +extension GroupChatVC: UITextFieldDelegate, UITableViewDataSource { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + let text = textField.text ?? "" + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } + viewModel.input.sendMessage.onNext(text) + textField.text = "" + hideMentionPicker() + return true + } + + func numberOfSections(in tableView: UITableView) -> Int { 1 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + tableView === rootView.mentionTableView ? mentionRows.count : 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "MentionCell", for: indexPath) + let row = mentionRows[indexPath.row] + cell.textLabel?.font = .systemFont(ofSize: 14) + cell.textLabel?.text = row.userId == nil ? "@所有人" : "@\(row.nickname)" + cell.selectionStyle = .default + return cell + } +} + // MARK: - UITableViewDelegate extension GroupChatVC: UITableViewDelegate { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -516,18 +807,32 @@ extension GroupChatVC: UITableViewDelegate { (cell as? EmojiSendMsgCell)?.stopAnimation() (cell as? EmojiReceivedMsgCell)?.stopAnimation() } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard tableView === rootView.mentionTableView else { return } + tableView.deselectRow(at: indexPath, animated: true) + let row = mentionRows[indexPath.row] + insertMention(userId: row.userId, nickname: row.nickname) + } } // MARK: - MessageListenerProxy private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { - private let handler: (OIMMessageInfo) -> Void + private let onMessage: (OIMMessageInfo) -> Void + private let onRevoked: (OIMMessageRevokedInfo) -> Void - init(handler: @escaping (OIMMessageInfo) -> Void) { - self.handler = handler + init(onMessage: @escaping (OIMMessageInfo) -> Void, + onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) { + self.onMessage = onMessage + self.onRevoked = onRevoked } func onRecvNewMessage(_ msg: OIMMessageInfo) { - handler(msg) + onMessage(msg) + } + + func onRecvMessageRevoked(_ messageRevoked: OIMMessageRevokedInfo) { + onRevoked(messageRevoked) } } @@ -562,6 +867,7 @@ extension GroupChatVC: PhotoPickerControllerDelegate { let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) // 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重 let localId = msg.clientMsgID ?? UUID().uuidString + viewModel.cacheMessage(msg) // 立即显示本地图片(带 loading) let localMsg = ChatMessage( diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index 183da401..3d8b36ce 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -32,6 +32,63 @@ struct ChatMessage { let timestamp: TimeInterval var showTime: Bool = false var isUploading: Bool = false + var quotePreview: QuotePreview? = nil + var atUserIDs: [String] = [] + var atNicknames: [String] = [] + var isAtAll: Bool = false + + func with(avatar: UIImage? = nil, + showTime: Bool? = nil, + isUploading: Bool? = nil, + imageUrl: String? = nil, + quotePreview: QuotePreview? = nil) -> ChatMessage { + ChatMessage( + id: id, + isSelf: isSelf, + senderId: senderId, + avatar: avatar ?? self.avatar, + senderName: senderName, + content: content, + voiceUrl: voiceUrl, + imageUrl: imageUrl ?? self.imageUrl, + imageWidth: imageWidth, + imageHeight: imageHeight, + timestamp: timestamp, + showTime: showTime ?? self.showTime, + isUploading: isUploading ?? self.isUploading, + quotePreview: quotePreview ?? self.quotePreview, + atUserIDs: atUserIDs, + atNicknames: atNicknames, + isAtAll: isAtAll + ) + } + + /// 正文 attributed(含 @ 高亮) + func attributedContent(isOutgoing: Bool) -> NSAttributedString { + let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333") + let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF") + let result = NSMutableAttributedString( + string: content, + attributes: [ + .font: UIFont.systemFont(ofSize: 14), + .foregroundColor: baseColor + ] + ) + var tokens = atNicknames.map { "@\($0)" } + if isAtAll { tokens.append("@所有人") } + for token in Set(tokens) where !token.isEmpty && token != "@" { + var searchRange = NSRange(location: 0, length: result.length) + while searchRange.location < result.length { + let found = (result.string as NSString).range(of: token, options: [], range: searchRange) + if found.location == NSNotFound { break } + result.addAttribute(.foregroundColor, value: atColor, range: found) + result.addAttribute(.font, value: UIFont.systemFont(ofSize: 14, weight: .semibold), range: found) + let next = found.location + found.length + searchRange = NSRange(location: next, length: result.length - next) + } + } + return result + } } class GroupChatView: UIView { @@ -45,6 +102,14 @@ class GroupChatView: UIView { /// 语音录制回调 var onVoiceRecordState: ((VoiceRecordState) -> Void)? private var emojiPlaybackGeneration = 0 + private var quoteBarHeightConstraint: NSLayoutConstraint? + + func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) { + quoteBar.isHidden = !visible + quoteBarHeightConstraint?.constant = visible ? 36 : 0 + if let summary { quoteBarLabel.text = summary } + UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() } + } private func setupRx() { @@ -55,11 +120,13 @@ class GroupChatView: UIView { let needsReset = !emojiPanelView.isHidden || !voiceRecordView.isHidden || textField.isFirstResponder + || !mentionPickerView.isHidden guard needsReset else { return } stopVisibleEmojiAnimations() emojiPanelView.isHidden = true voiceRecordView.isHidden = true + mentionPickerView.isHidden = true if !excludeTextField { textField.resignFirstResponder() } UIView.animate(withDuration: 0.25) { self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20) @@ -123,6 +190,8 @@ class GroupChatView: UIView { addSubview(tableView) addSubview(chatWarningView) + addSubview(quoteBar) + addSubview(mentionPickerView) addSubview(bottomBar) addSubview(disableIMView) bottomBar.addSubview(bottomBarCornerView) @@ -136,6 +205,11 @@ class GroupChatView: UIView { bottomBar.addSubview(addBtn) bottomBar.addSubview(sendBtn) + quoteBar.addSubview(quoteBarLabel) + quoteBar.addSubview(quoteBarCloseBtn) + mentionPickerView.addSubview(mentionPickerContentView) + mentionPickerContentView.addSubview(mentionTableView) + navBgView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) @@ -196,6 +270,28 @@ class GroupChatView: UIView { chatWarningView.layoutChain .topToBottomOfView(navBarView) .edgesHorzontal() + + quoteBar.layoutChain + .edgesHorzontal(15) + .bottomToTopOfView(bottomBar, offset: -6) + quoteBarHeightConstraint = quoteBar.heightAnchor.constraint(equalToConstant: 0) + quoteBarHeightConstraint?.isActive = true + + quoteBarLabel.layoutChain + .left(12).rightToLeftOfView(quoteBarCloseBtn, offset: -8) + .centerY() + + quoteBarCloseBtn.layoutChain + .right(8).centerY() + .width(24).height(24) + + mentionPickerView.layoutChain + .edgesHorzontal(15) + .height(180) + .bottomToTopOfView(quoteBar, offset: -6) + + mentionPickerContentView.layoutChain.edges() + mentionTableView.layoutChain.edges() bottomBar.layoutChain .edgesHorzontal(15) @@ -395,6 +491,63 @@ class GroupChatView: UIView { return tv }() + // MARK: - Quote / Mention + lazy var quoteBar: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#EEF6FB") + view.cornerRadius = 10 + view.isHidden = true + return view + }() + + lazy var quoteBarLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hexStr: "#666666") + label.lineBreakMode = .byTruncatingTail + return label + }() + + lazy var quoteBarCloseBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("✕", for: .normal) + btn.setTitleColor(UIColor(hexStr: "#999999"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 14) + return btn + }() + + lazy var mentionPickerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.isHidden = true + // 阴影在外层;圆角裁剪在内层,避免 clipsToBounds 吃掉阴影 + view.layer.shadowColor = UIColor(hexStr: "#0F2846").cgColor + view.layer.shadowOpacity = 0.18 + view.layer.shadowRadius = 12 + view.layer.shadowOffset = CGSize(width: 0, height: 4) + view.layer.masksToBounds = false + return view + }() + + lazy var mentionPickerContentView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 12 + view.layer.borderWidth = 0.5 + view.layer.borderColor = UIColor(hexStr: "#E5E5E5").cgColor + view.clipsToBounds = true + return view + }() + + lazy var mentionTableView: UITableView = { + let tv = UITableView(frame: .zero, style: .plain) + tv.separatorStyle = .singleLine + tv.rowHeight = 44 + tv.backgroundColor = .white + tv.register(UITableViewCell.self, forCellReuseIdentifier: "MentionCell") + return tv + }() + // MARK: - Bottom Bar lazy var bottomBar: UIView = { let view = UIView() @@ -537,35 +690,236 @@ class GroupChatView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + if !mentionPickerView.isHidden { + mentionPickerView.layer.shadowPath = UIBezierPath( + roundedRect: mentionPickerView.bounds, + cornerRadius: 12 + ).cgPath + } + } +} + +// MARK: - Quote block(气泡外侧下方) +final class ChatQuoteBlockView: UIControl { + private let lineView: UIView = { + let v = UIView() + v.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55) + return v + }() + private let label: UILabel = { + let lab = UILabel() + lab.font = .systemFont(ofSize: 11) + lab.textColor = UIColor(hexStr: "#666666") + lab.numberOfLines = 2 + return lab + }() + + override init(frame: CGRect) { + super.init(frame: frame) + cornerRadius = 4 + backgroundColor = UIColor(hexStr: "#EEEEF0") + isHidden = true +// addSubview(lineView) + addSubview(label) +// lineView.layoutChain.left().top(2).bottom(2).width(2) + label.layoutChain.edgesHorzontal(10).edgesVertical() +// label.layoutChain.leftToRightOfView(lineView, offset: 6).right().top().bottom() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(_ preview: QuotePreview?) { + guard let preview else { + isHidden = true + return + } + isHidden = false + let name = preview.senderName.isEmpty ? "" : "\(preview.senderName): " + label.text = name + preview.summary + label.textColor = UIColor(hexStr: "#666666") + lineView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55) + } +} + +// MARK: - 气泡上方操作菜单 +final class ChatBubbleActionMenu: UIView { + var onQuote: (() -> Void)? + var onRevoke: (() -> Void)? + var onDismiss: (() -> Void)? + + private let panel = UIView() + private let arrow = UIView() + private let stack = UIStackView() + private let dimView = UIControl() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + dimView.backgroundColor = .clear + dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) + addSubview(dimView) + dimView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dimView.topAnchor.constraint(equalTo: topAnchor), + dimView.leadingAnchor.constraint(equalTo: leadingAnchor), + dimView.trailingAnchor.constraint(equalTo: trailingAnchor), + dimView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + panel.backgroundColor = UIColor(hexStr: "#333333") + panel.layer.cornerRadius = 10 + panel.clipsToBounds = true + addSubview(panel) + + stack.axis = .horizontal + stack.alignment = .fill + stack.distribution = .fill + stack.spacing = 0 + panel.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: panel.topAnchor), + stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor), + panel.heightAnchor.constraint(equalToConstant: 44) + ]) + + arrow.backgroundColor = UIColor(hexStr: "#333333") + addSubview(arrow) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show(in host: UIView, anchor: UIView, showRevoke: Bool) { + removeFromSuperview() + host.addSubview(self) + frame = host.bounds + autoresizingMask = [.flexibleWidth, .flexibleHeight] + + stack.arrangedSubviews.forEach { + stack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote)) + quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true + stack.addArrangedSubview(quoteBtn) + if showRevoke { + let sep = UIView() + sep.backgroundColor = UIColor.white.withAlphaComponent(0.2) + sep.translatesAutoresizingMaskIntoConstraints = false + sep.widthAnchor.constraint(equalToConstant: 1).isActive = true + stack.addArrangedSubview(sep) + let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke)) + revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true + stack.addArrangedSubview(revokeBtn) + } + + let panelW: CGFloat = showRevoke ? 145 : 72 + let panelH: CGFloat = 44 + let arrowSize: CGFloat = 8 + let gap: CGFloat = 6 + + layoutIfNeeded() + let anchorRect = anchor.convert(anchor.bounds, to: host) + var panelX = anchorRect.midX - panelW / 2 + panelX = max(12, min(panelX, host.bounds.width - panelW - 12)) + + var panelY = anchorRect.minY - gap - panelH - arrowSize + var arrowAbove = true + if panelY < host.safeAreaInsets.top + 8 { + panelY = anchorRect.maxY + gap + arrowSize + arrowAbove = false + } + + panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH) + let arrowX = min(max(anchorRect.midX - arrowSize / 2, panelX + 10), panelX + panelW - arrowSize - 10) + if arrowAbove { + arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowSize, height: arrowSize) + } else { + arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowSize + 1, width: arrowSize, height: arrowSize) + } + arrow.transform = CGAffineTransform(rotationAngle: .pi / 4) + + alpha = 0 + UIView.animate(withDuration: 0.15) { self.alpha = 1 } + } + + private func makeButton(title: String, action: Selector) -> UIButton { + let btn = UIButton(type: .system) + btn.setTitle(title, for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + btn.addTarget(self, action: action, for: .touchUpInside) + btn.translatesAutoresizingMaskIntoConstraints = false + return btn + } + + @objc private func handleQuote() { + onQuote?() + dismiss() + } + + @objc private func handleRevoke() { + onRevoke?() + dismiss() + } + + @objc private func handleDismiss() { dismiss() } + + func dismiss() { + UIView.animate(withDuration: 0.12, animations: { + self.alpha = 0 + }, completion: { _ in + self.onDismiss?() + self.removeFromSuperview() + }) + } +} + +private func chatFormatTime(_ t: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: t) + let now = Date() + let calendar = Calendar.current + let f = DateFormatter() + if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } + else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } + else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } + else { f.dateFormat = "yyyy-M-d HH:mm" } + return f.string(from: date) } // MARK: - 发送的消息cell class TextSendMsgCell: UITableViewCell { - + + var onQuoteTap: (() -> Void)? + private(set) var messageId: String = "" + var menuAnchorView: UIView { bubbleView } + func configure(_ msg: ChatMessage) { + messageId = msg.id timeLabel.isHidden = !msg.showTime - timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil + timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - contentLabel.text = msg.content + contentLabel.attributedText = msg.attributedContent(isOutgoing: true) + let hasQuote = msg.quotePreview != nil + quoteBlock.configure(msg.quotePreview) + quoteHeightConstraint?.constant = hasQuote ? 28 : 0 + quoteTopConstraint?.constant = hasQuote ? 4 : 0 } - - private func formatTime(_ t: TimeInterval) -> String { - let date = Date(timeIntervalSince1970: t) - let now = Date() - let calendar = Calendar.current - let f = DateFormatter() - if calendar.isDateInToday(date) { - f.dateFormat = "HH:mm" - } else if calendar.isDateInYesterday(date) { - f.dateFormat = "'昨天' HH:mm" - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - f.dateFormat = "M-d HH:mm" - } else { - f.dateFormat = "yyyy-M-d HH:mm" + + func flashHighlight() { + let old = contentView.backgroundColor + contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) + UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { + self.contentView.backgroundColor = old } - return f.string(from: date) } - + private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) @@ -574,7 +928,7 @@ class TextSendMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 @@ -597,80 +951,102 @@ class TextSendMsgCell: UITableViewCell { label.numberOfLines = 0 return label }() - + + private let quoteBlock = ChatQuoteBlockView() + private var quoteHeightConstraint: NSLayoutConstraint? + private var quoteTopConstraint: NSLayoutConstraint? + override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear + contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) - contentView.addSubview(timeLabel) + contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) - timeLabel.layoutChain - .top() - .centerX() - + timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) - .right(12) - .width(30).height(30) - + .right(12).width(30).height(30) bubbleView.layoutChain .topToBottomOfView(avatarView, offset: -15) .rightToView(avatarView, offset: -13) .left(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) - .bottom(10) - - contentLabel.layoutChain - .edgesVertical(10) - .edgesHorzontal(20) + + contentLabel.translatesAutoresizingMaskIntoConstraints = false + quoteBlock.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), + contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), + contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), + contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10) + ]) + + let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) + let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) + quoteTopConstraint = qTop + quoteHeightConstraint = qH + NSLayoutConstraint.activate([ + qTop, + quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor), + quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60), + quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), + quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + qH + ]) + quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - + @objc private func handleQuoteTap() { onQuoteTap?() } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft ,.bottomLeft, .bottomRight], + bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } -//MARK: - 收到的消息cell +// MARK: - 收到的消息cell class TextReceivedMsgCell: UITableViewCell { - + + var onQuoteTap: (() -> Void)? + private(set) var messageId: String = "" + private(set) var senderId: String = "" + private(set) var senderName: String = "" + var menuAnchorView: UIView { bubbleView } + func configure(_ msg: ChatMessage) { + messageId = msg.id + senderId = msg.senderId + senderName = msg.senderName timeLabel.isHidden = !msg.showTime - timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil + timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName - contentLabel.text = msg.content + contentLabel.attributedText = msg.attributedContent(isOutgoing: false) + let hasQuote = msg.quotePreview != nil + quoteBlock.configure(msg.quotePreview) + quoteHeightConstraint?.constant = hasQuote ? 28 : 0 + quoteTopConstraint?.constant = hasQuote ? 4 : 0 } - - private func formatTime(_ t: TimeInterval) -> String { - let date = Date(timeIntervalSince1970: t) - let now = Date() - let calendar = Calendar.current - let f = DateFormatter() - if calendar.isDateInToday(date) { - f.dateFormat = "HH:mm" - } else if calendar.isDateInYesterday(date) { - f.dateFormat = "'昨天' HH:mm" - } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { - f.dateFormat = "M-d HH:mm" - } else { - f.dateFormat = "yyyy-M-d HH:mm" + + func flashHighlight() { + let old = contentView.backgroundColor + contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) + UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { + self.contentView.backgroundColor = old } - return f.string(from: date) } - + private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) @@ -686,7 +1062,7 @@ class TextReceivedMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 @@ -694,6 +1070,7 @@ class TextReceivedMsgCell: UITableViewCell { iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white + iv.isUserInteractionEnabled = true return iv }() @@ -709,7 +1086,11 @@ class TextReceivedMsgCell: UITableViewCell { label.numberOfLines = 0 return label }() - + + private let quoteBlock = ChatQuoteBlockView() + private var quoteHeightConstraint: NSLayoutConstraint? + private var quoteTopConstraint: NSLayoutConstraint? + override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -718,44 +1099,61 @@ class TextReceivedMsgCell: UITableViewCell { contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) + contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) contentView.addSubview(nameLabel) - - timeLabel.layoutChain - .top() - .centerX() - + + timeLabel.layoutChain.top().centerX() bubbleView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .left(30) .right(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) - - avatarView.layoutChain - .topToBottomOfView(bubbleView, offset: -15) - .left(12) - .width(30).height(30) - .bottom(10) - - nameLabel.layoutChain - .leftToRightOfView(avatarView, offset: 5) - .bottomToView(avatarView) - - contentLabel.layoutChain - .edgesVertical(10) - .edgesHorzontal(20) + + contentLabel.translatesAutoresizingMaskIntoConstraints = false + quoteBlock.translatesAutoresizingMaskIntoConstraints = false + avatarView.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), + contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), + contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), + contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10), + // 头像 + 昵称贴在气泡下方 + avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15), + avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + avatarView.widthAnchor.constraint(equalToConstant: 30), + avatarView.heightAnchor.constraint(equalToConstant: 30), + nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), + nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor) + ]) + + // 引用在 senderName 下方 + let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4) + let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) + quoteTopConstraint = qTop + quoteHeightConstraint = qH + NSLayoutConstraint.activate([ + qTop, + quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), + quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60), + quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), + quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), + qH + ]) + quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - + @objc private func handleQuoteTap() { onQuoteTap?() } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight], + bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } @@ -823,6 +1221,7 @@ final class NotificationMsgCell: UITableViewCell { // MARK: - 发送的表情消息 final class EmojiSendMsgCell: UITableViewCell { + var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() @@ -938,6 +1337,7 @@ final class EmojiSendMsgCell: UITableViewCell { // MARK: - 收到的表情消息 final class EmojiReceivedMsgCell: UITableViewCell { + var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() @@ -947,7 +1347,7 @@ final class EmojiReceivedMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 @@ -955,6 +1355,7 @@ final class EmojiReceivedMsgCell: UITableViewCell { iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white + iv.isUserInteractionEnabled = true return iv }() @@ -1101,6 +1502,7 @@ extension GroupChatView: UICollectionViewDelegate { // MARK: - 发送的语音消息 final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { + var menuAnchorView: UIView { bubbleView } private let timeLabel: UILabel = { let label = UILabel() @@ -1238,6 +1640,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { // MARK: - 收到的语音消息 final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { + var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime @@ -1289,7 +1692,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 @@ -1297,6 +1700,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white + iv.isUserInteractionEnabled = true return iv }() @@ -1394,7 +1798,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { // MARK: - 发送的图片消息 final class ImageSendMsgCell: UITableViewCell { - + var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? func configure(_ msg: ChatMessage) { @@ -1504,7 +1908,7 @@ final class ImageSendMsgCell: UITableViewCell { // MARK: - 收到的图片消息 final class ImageReceivedMsgCell: UITableViewCell { - + var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? func configure(_ msg: ChatMessage) { @@ -1549,7 +1953,7 @@ final class ImageReceivedMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 @@ -1557,6 +1961,7 @@ final class ImageReceivedMsgCell: UITableViewCell { iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white + iv.isUserInteractionEnabled = true return iv }() diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index ba27fec1..0de543fe 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -11,6 +11,17 @@ import RxCocoa import OpenIMSDK import Differentiator +enum QuoteKind { + case text, image, voice, emoji, unknown +} + +struct QuotePreview { + let senderName: String + let kind: QuoteKind + let summary: String + let quotedClientMsgID: String? +} + enum ChatSectionItem { case send(ChatMessage) case received(ChatMessage) @@ -21,6 +32,7 @@ enum ChatSectionItem { case imageSend(ChatMessage) case imageReceived(ChatMessage) case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) + case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) } typealias ChatSectionModel = SectionModel @@ -33,6 +45,7 @@ final class GroupChatViewModel { struct Output { let messages: Observable<[ChatSectionItem]> + let pendingQuoteSummary: Observable } let input: Input @@ -40,22 +53,42 @@ final class GroupChatViewModel { private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: []) private let sendMessageSubject = PublishSubject() + private let pendingQuoteSummarySubject = BehaviorRelay(value: nil) private var lastTimeGap: TimeInterval = 0 - /// 两条消息间隔超过此值(秒)显示时间戳 - private let timeGapThreshold: TimeInterval = 300 // 5 minutes + private let timeGapThreshold: TimeInterval = 300 var groupModel: GroupInfoModel? var memberList: [GroupMemberModel] = [] { - didSet { - buildAvatarCache() - } + didSet { buildAvatarCache() } } var groupId: String = "" + /// 引用中的原始消息 + private(set) var pendingQuote: OIMMessageInfo? + /// 待发送 @ 成员 + private(set) var pendingMentions: [(userId: String, nickname: String)] = [] + private(set) var pendingAtAll: Bool = false + + /// clientMsgID → 原始消息(引用 / 定位) + private(set) var messageCache: [String: OIMMessageInfo] = [:] + + /// 定位跳转 / 加载更早历史时抑制自动滚底 + var suppressAutoScroll = false + /// 是否还有更早历史 + private(set) var hasMoreHistory = true + private var isLoadingOlder = false + + private let disposeBag = DisposeBag() + private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: []) + private let historyPageSize = 30 + // MARK: - Init init() { input = Input(sendMessage: sendMessageSubject.asObserver()) - output = Output(messages: messagesSubject.asObservable()) + output = Output( + messages: messagesSubject.asObservable(), + pendingQuoteSummary: pendingQuoteSummarySubject.asObservable() + ) sendMessageSubject .subscribe(onNext: { [weak self] text in @@ -64,54 +97,36 @@ final class GroupChatViewModel { .disposed(by: disposeBag) } - private let disposeBag = DisposeBag() + var currentMessages: [ChatSectionItem] { messagesSubject.value } + + var conversationID: String { "sg_\(groupId)" } // MARK: - Avatar private var avatarCache: [String: UIImage] = [:] - /// memberList 更新后调用,预构建 userID → UIImage 映射,避免 30 条消息逐一遍历 func buildAvatarCache() { var cache: [String: UIImage] = [:] for member in memberList { cache[member.user_id] = member.userIcon } avatarCache = cache - // 刷新已有消息的头像(并行加载时 loadMessages 可能先于 memberList 完成) refreshMessageAvatars() } - /// 用 avatarCache 刷新已有消息的头像(并行加载时 loadMessages 可能先于 memberList 完成) private func refreshMessageAvatars() { var items = messagesSubject.value var didChange = false items = items.map { item in - switch item { - case var .send(m): if updateAvatar(&m) { didChange = true }; return .send(m) - case var .received(m): if updateAvatar(&m) { didChange = true }; return .received(m) - case var .emojiSend(m): if updateAvatar(&m) { didChange = true }; return .emojiSend(m) - case var .emojiReceived(m): if updateAvatar(&m) { didChange = true }; return .emojiReceived(m) - case var .voiceSend(m): if updateAvatar(&m) { didChange = true }; return .voiceSend(m) - case var .voiceReceived(m): if updateAvatar(&m) { didChange = true }; return .voiceReceived(m) - case var .imageSend(m): if updateAvatar(&m) { didChange = true }; return .imageSend(m) - case var .imageReceived(m): if updateAvatar(&m) { didChange = true }; return .imageReceived(m) - default: return item - } - } - if didChange { - messagesSubject.accept(items) + guard var m = item.chatMessage, updateAvatar(&m) else { return item } + didChange = true + return ChatSectionItem.with(m) } + if didChange { messagesSubject.accept(items) } } - /// 尝试用缓存更新单条消息的头像,返回是否变更 private func updateAvatar(_ msg: inout ChatMessage) -> Bool { guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false } - msg = ChatMessage( - id: msg.id, isSelf: msg.isSelf, senderId: msg.senderId, - avatar: cached, senderName: msg.senderName, - content: msg.content, voiceUrl: msg.voiceUrl, imageUrl: msg.imageUrl, - imageWidth: msg.imageWidth, imageHeight: msg.imageHeight, - timestamp: msg.timestamp, showTime: msg.showTime, isUploading: msg.isUploading - ) + msg = msg.with(avatar: cached) return true } @@ -129,29 +144,359 @@ final class GroupChatViewModel { memberList.first { id == $0.user_id }?.nick_name ?? "" } + // MARK: - Quote / Mention state + func setPendingQuote(_ msg: OIMMessageInfo?) { + pendingQuote = msg + if let msg { + let preview = makeQuotePreview(from: msg) + let name = preview.senderName.isEmpty ? "消息" : preview.senderName + pendingQuoteSummarySubject.accept("\(name): \(preview.summary)") + } else { + pendingQuoteSummarySubject.accept(nil) + } + } + + func clearPendingQuote() { + setPendingQuote(nil) + } + + func mentionUser(userId: String, nickname: String) -> String { + let name = nickname.isEmpty ? getUserNickName(id: userId) : nickname + let display = name.isEmpty ? userId : name + if !pendingMentions.contains(where: { $0.userId == userId }) { + pendingMentions.append((userId, display)) + } + return "@\(display) " + } + + func setPendingAtAll(_ value: Bool) { + pendingAtAll = value + } + + func clearPendingMentions() { + pendingMentions = [] + pendingAtAll = false + } + + func mentionCandidates(excludingSelf: Bool = true) -> [GroupMemberModel] { + let selfId = AppContextManager.shared.userId + return memberList.filter { !excludingSelf || $0.user_id != selfId } + } + + func oimMessage(forClientMsgID id: String) -> OIMMessageInfo? { + messageCache[id] + } + // MARK: - Send private func onSendMessage(_ text: String) { - let msg = OIMMessageInfo.createTextMessage(text) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let quote = pendingQuote + let atAll = pendingAtAll + let mentions = pendingMentions + + let msg: OIMMessageInfo + if atAll { + msg = OIMMessageInfo.createText(atAllMessage: trimmed, + displayText: "@所有人", + message: quote) + } else if !mentions.isEmpty { + let ids = mentions.map(\.userId) + let infos: [OIMAtInfo] = mentions.map { m in + let info = OIMAtInfo() + info.atUserID = m.userId + info.groupNickname = m.nickname + return info + } + msg = OIMMessageInfo.createText(atMessage: trimmed, + atUsersID: ids, + atUsersInfo: infos, + message: quote) + } else if let quote { + msg = OIMMessageInfo.createQuoteMessage(trimmed, message: quote) + } else { + msg = OIMMessageInfo.createTextMessage(trimmed) + } + + clearPendingQuote() + clearPendingMentions() OIMManager.manager.sendMessage(msg, recvID: "", groupID: groupId, offlinePushInfo: nil, onSuccess: { [weak self] _ in + self?.cacheMessage(msg) self?.appendMessage(msg) }, onProgress: nil as OIMNumberCallback?, onFailure: { code, errMsg in print("send failed: \(code) \(errMsg ?? "")") + DispatchQueue.main.async { + DLToast.showError(text: errMsg ?? "发送失败") + } }) } + // MARK: - Revoke + func revokeMessage(clientMsgID: String) { + OIMManager.manager.revokeMessage(conversationID, + clientMsgID: clientMsgID, + onSuccess: { [weak self] _ in + DispatchQueue.main.async { + self?.applyRevoke(clientMsgID: clientMsgID, + revokerID: AppContextManager.shared.userId, + revokerNickname: AppContextManager.shared.name) + } + }, onFailure: { code, errMsg in + DispatchQueue.main.async { + DLToast.showError(text: errMsg ?? "撤回失败(\(code))") + } + }) + } + + func onMessageRevoked(_ info: OIMMessageRevokedInfo) { + applyRevoke(clientMsgID: info.clientMsgID, + revokerID: info.revokerID, + revokerNickname: info.revokerNickname) + } + + private func applyRevoke(clientMsgID: String, revokerID: String, revokerNickname: String) { + if pendingQuote?.clientMsgID == clientMsgID { + clearPendingQuote() + } + messageCache.removeValue(forKey: clientMsgID) + + var items = messagesSubject.value + guard let idx = items.firstIndex(where: { $0.chatMessage?.id == clientMsgID || $0.revokedClientMsgID == clientMsgID }) else { + // 仍更新引用摘要文案 + refreshQuotePreviewsAfterRevoke(clientMsgID) + return + } + let ts = timestampFrom(item: items[idx]) + let showTime: Bool = { + switch items[idx] { + case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), + let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): + return m.showTime + case let .notification(_, s, _), let .revoked(_, s, _): + return s + } + }() + let tip = revokeTip(revokerID: revokerID, revokerNickname: revokerNickname) + items[idx] = .revoked(tip, showTime: showTime, timestamp: ts) + messagesSubject.accept(items) + refreshQuotePreviewsAfterRevoke(clientMsgID) + } + + private func revokeTip(revokerID: String, revokerNickname: String) -> NSAttributedString { + let isSelf = revokerID == AppContextManager.shared.userId + let text = isSelf ? "你撤回了一条消息" : "\(revokerNickname.isEmpty ? "对方" : revokerNickname) 撤回了一条消息" + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: 12), + .foregroundColor: UIColor(hexStr: "#999999") + ]) + } + + private func refreshQuotePreviewsAfterRevoke(_ revokedId: String) { + var items = messagesSubject.value + var changed = false + for i in items.indices { + guard var m = items[i].chatMessage, + m.quotePreview?.quotedClientMsgID == revokedId else { continue } + m.quotePreview = QuotePreview( + senderName: m.quotePreview?.senderName ?? "", + kind: .unknown, + summary: "引用内容已撤回", + quotedClientMsgID: revokedId + ) + items[i] = ChatSectionItem.with(m) + changed = true + } + if changed { messagesSubject.accept(items) } + } + + // MARK: - Locate quoted message + func indexPath(forClientMsgID id: String) -> IndexPath? { + guard let idx = messagesSubject.value.firstIndex(where: { $0.chatMessage?.id == id }) else { return nil } + return IndexPath(row: idx, section: 0) + } + + /// 若目标不在列表,向上分页加载直至找到或达上限 + func ensureMessageVisible(clientMsgID: String, maxPages: Int = 5, completion: @escaping (Bool) -> Void) { + if indexPath(forClientMsgID: clientMsgID) != nil { + completion(true) + return + } + + // 先确认消息是否仍存在 + let param = OIMFindMessageListParam() + param.conversationID = conversationID + param.clientMsgIDList = [clientMsgID] + OIMManager.manager.findMessageList([param], onSuccess: { [weak self] result in + guard let self = self else { return } + let found = (result?.findResultItems ?? []).contains { item in + (item.messageList).contains { $0.clientMsgID == clientMsgID } + } + // 找不到或已撤回 + if !found { + if let cached = self.messageCache[clientMsgID], + cached.status.rawValue == OIMMessageStatus.revoke.rawValue || cached.contentType.rawValue == 2101 { + DispatchQueue.main.async { completion(false) } + return + } + // find 可能因 SDK 差异失败,仍尝试拉历史 + } + self.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion) + }, onFailure: { [weak self] _, _ in + self?.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion) + }) + } + + /// 结果:是否成功拉取到新消息、是否还有更多 + struct LoadOlderResult { + let prependedCount: Int + let hasMore: Bool + } + + /// 下拉加载更早历史(供 UI 触发) + func loadOlderHistory(completion: ((LoadOlderResult) -> Void)? = nil) { + guard !isLoadingOlder else { + completion?(LoadOlderResult(prependedCount: 0, hasMore: hasMoreHistory)) + return + } + guard hasMoreHistory else { + completion?(LoadOlderResult(prependedCount: 0, hasMore: false)) + return + } + guard let oldest = oldestClientMsgID() else { + hasMoreHistory = false + completion?(LoadOlderResult(prependedCount: 0, hasMore: false)) + return + } + + isLoadingOlder = true + fetchOlderHistory(startClientMsgID: oldest) { [weak self] list, isEnd, success in + guard let self = self else { return } + self.isLoadingOlder = false + guard success else { + DispatchQueue.main.async { + completion?(LoadOlderResult(prependedCount: 0, hasMore: self.hasMoreHistory)) + } + return + } + if list.isEmpty { + self.hasMoreHistory = false + DispatchQueue.main.async { + completion?(LoadOlderResult(prependedCount: 0, hasMore: false)) + } + return + } + let count = self.prependHistory(list) + if isEnd || count == 0 { + self.hasMoreHistory = false + } + DispatchQueue.main.async { + completion?(LoadOlderResult(prependedCount: count, hasMore: self.hasMoreHistory)) + } + } + } + + private func loadOlderPages(targeting clientMsgID: String, remaining: Int, completion: @escaping (Bool) -> Void) { + if indexPath(forClientMsgID: clientMsgID) != nil { + DispatchQueue.main.async { completion(true) } + return + } + guard remaining > 0, hasMoreHistory else { + DispatchQueue.main.async { completion(false) } + return + } + loadOlderHistory { [weak self] result in + guard let self = self else { return } + if self.indexPath(forClientMsgID: clientMsgID) != nil { + completion(true) + } else if result.prependedCount > 0, result.hasMore { + self.loadOlderPages(targeting: clientMsgID, remaining: remaining - 1, completion: completion) + } else { + completion(false) + } + } + } + + private func oldestClientMsgID() -> String? { + for item in messagesSubject.value { + if let id = item.chatMessage?.id { return id } + } + return nil + } + + private func fetchOlderHistory(startClientMsgID: String, + completion: @escaping ([OIMMessageInfo], Bool, Bool) -> Void) { + let param = OIMGetAdvancedHistoryMessageListParam() + param.conversationID = conversationID + param.count = historyPageSize + param.startClientMsgID = startClientMsgID + + OIMManager.manager.getAdvancedHistoryMessageList( + param, + onSuccess: { [weak self] result in + let pageSize = self?.historyPageSize ?? 30 + let list = result?.messageList ?? [] + let isEnd = result?.isEnd ?? (list.isEmpty || list.count < pageSize) + completion(list, isEnd, true) + }, + onFailure: { code, msg in + print("loadOlderHistory failed: \(code) \(msg ?? "")") + completion([], false, false) + }) + } + + /// 将更早消息插入列表头部,返回实际新增条数 + @discardableResult + private func prependHistory(_ list: [OIMMessageInfo]) -> Int { + var newItems: [ChatSectionItem] = [] + for msg in list { + cacheMessage(msg) + if let item = toSectionItem(msg) { + newItems.append(item) + } + } + newItems.sort { timestampFrom(item: $0) < timestampFrom(item: $1) } + + let items = messagesSubject.value + let existingIDs = Set(items.compactMap { $0.chatMessage?.id }) + newItems = newItems.filter { item in + guard let id = item.chatMessage?.id else { return true } + return !existingIDs.contains(id) + } + guard !newItems.isEmpty else { return 0 } + + var result = newItems + items + for i in result.indices { + let ts = timestampFrom(item: result[i]) + let show = i == 0 || ts - timestampFrom(item: result[i - 1]) >= timeGapThreshold + result[i] = setShowTime(result[i], show) + } + let count = newItems.count + // 同步更新,便于调用方立刻根据 contentSize 校正 offset + suppressAutoScroll = true + if Thread.isMainThread { + messagesSubject.accept(result) + } else { + DispatchQueue.main.sync { + self.messagesSubject.accept(result) + } + } + return count + } + // MARK: - Load func loadMessages() { guard !groupId.isEmpty else { return } let param = OIMGetAdvancedHistoryMessageListParam() - param.conversationID = "sg_\(groupId)" - param.count = 30 + param.conversationID = conversationID + param.count = historyPageSize OIMManager.manager.getAdvancedHistoryMessageList( param, @@ -160,29 +505,27 @@ final class GroupChatViewModel { let list = result?.messageList else { return } var items: [ChatSectionItem] = [] for msg in list { + self.cacheMessage(msg) if let item = self.toSectionItem(msg) { items.append(item) } } - // messages come newest-first from API, sort by time ascending items.sort { - let t1 = self.timestampFrom(item: $0) - let t2 = self.timestampFrom(item: $1) - return t1 < t2 + self.timestampFrom(item: $0) < self.timestampFrom(item: $1) } - // set showTime flag for i in items.indices { let ts = self.timestampFrom(item: items[i]) - if i == 0 || ts - self.timestampFrom(item: items[i-1]) >= self.timeGapThreshold { + if i == 0 || ts - self.timestampFrom(item: items[i - 1]) >= self.timeGapThreshold { items[i] = self.setShowTime(items[i], true) } } if let last = items.last { self.lastTimeGap = self.timestampFrom(item: last) } + let isEnd = result?.isEnd ?? (list.count < self.historyPageSize) DispatchQueue.main.async { + self.hasMoreHistory = !isEnd && !list.isEmpty self.messagesSubject.accept(items) - // 进入会话后清除未读 self.markAsRead() } }, @@ -191,35 +534,22 @@ final class GroupChatViewModel { }) } - /// 标记当前会话为已读 private func markAsRead() { - let conversationID = "sg_\(groupId)" OIMManager.manager.markConversationMessage(asRead: conversationID, - onSuccess: nil, - onFailure: nil) + onSuccess: nil, + onFailure: nil) } - /// 本地消息(发送中) func appendLocalMessage(_ item: ChatSectionItem) { var items = messagesSubject.value items.append(item) messagesSubject.accept(items) } - /// 根据 id 更新本地消息(图片上传成功/失败后替换本地占位消息) func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) { var items = messagesSubject.value - guard let idx = items.firstIndex(where: { item in - switch item { - case let .imageSend(m): return m.id == id - case let .voiceSend(m): return m.id == id - case let .send(m): return m.id == id - case let .emojiSend(m): return m.id == id - default: return false - } - }), - var chatMsg = items[idx].chatMessage - else { return } + guard let idx = items.firstIndex(where: { $0.chatMessage?.id == id }), + var chatMsg = items[idx].chatMessage else { return } update(&chatMsg) items[idx] = ChatSectionItem.with(chatMsg) messagesSubject.accept(items) @@ -227,11 +557,11 @@ final class GroupChatViewModel { // MARK: - Receive func onReceiveMessage(_ msg: OIMMessageInfo) { + cacheMessage(msg) guard let item = toSectionItem(msg) else { return } let ts = timestampFrom(item: item) var items = messagesSubject.value - // 去重:如果 clientMsgID 已存在(本地占位消息),跳过监听器追加 if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty, items.contains(where: { $0.chatMessage?.id == clientMsgID }) { return @@ -245,11 +575,15 @@ final class GroupChatViewModel { } } - // MARK: - Append sent message private func appendMessage(_ msg: OIMMessageInfo) { + cacheMessage(msg) guard let item = toSectionItem(msg) else { return } let ts = timestampFrom(item: item) var items = messagesSubject.value + // 去重 + if let id = msg.clientMsgID, items.contains(where: { $0.chatMessage?.id == id }) { + return + } let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold lastTimeGap = ts items.append(showTime ? setShowTime(item, true) : item) @@ -258,39 +592,51 @@ final class GroupChatViewModel { } } + func cacheMessage(_ msg: OIMMessageInfo) { + if let id = msg.clientMsgID, !id.isEmpty { + messageCache[id] = msg + } + } + + // MARK: - Helpers private func timestampFrom(item: ChatSectionItem) -> TimeInterval { switch item { case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): return m.timestamp - case let .notification(_, _, ts): return ts + case let .notification(_, _, ts), let .revoked(_, _, ts): + return ts } } private func setShowTime(_ item: ChatSectionItem, _ show: Bool) -> ChatSectionItem { switch item { - case var .send(m): m.showTime = show; return .send(m) - case var .received(m): m.showTime = show; return .received(m) - case var .emojiSend(m): m.showTime = show; return .emojiSend(m) - case var .emojiReceived(m):m.showTime = show; return .emojiReceived(m) - case var .voiceSend(m): m.showTime = show; return .voiceSend(m) - case var .voiceReceived(m):m.showTime = show; return .voiceReceived(m) - case var .imageSend(m): m.showTime = show; return .imageSend(m) - case var .imageReceived(m):m.showTime = show; return .imageReceived(m) + case var .send(m): m.showTime = show; return .send(m) + case var .received(m): m.showTime = show; return .received(m) + case var .emojiSend(m): m.showTime = show; return .emojiSend(m) + case var .emojiReceived(m): m.showTime = show; return .emojiReceived(m) + case var .voiceSend(m): m.showTime = show; return .voiceSend(m) + case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m) + case var .imageSend(m): m.showTime = show; return .imageSend(m) + case var .imageReceived(m): m.showTime = show; return .imageReceived(m) case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts) + case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts) } } private func convert(_ msg: OIMMessageInfo) -> ChatMessage { let isSelf = msg.isSelf() let ts = TimeInterval(msg.sendTime) / 1000.0 - let content: String - let voiceUrl: String - let imageUrl: String - let imageW: CGFloat - let imageH: CGFloat - - // 图片消息: 计算图片的显示宽高 + var content = "" + var voiceUrl = "" + var imageUrl = "" + var imageW: CGFloat = 0 + var imageH: CGFloat = 0 + var quotePreview: QuotePreview? + var atUserIDs: [String] = [] + var atNicknames: [String] = [] + var isAtAll = false + let maxWH: CGFloat = 200 var msgImageW: CGFloat = 0 var msgImageH: CGFloat = 0 @@ -304,23 +650,37 @@ final class GroupChatViewModel { msgImageW = maxWH * CGFloat(width) / CGFloat(height) } } - - if let sound = msg.soundElem { + + let type = msg.contentType.rawValue + if type == 103, let sound = msg.soundElem { + content = "\(sound.duration)" + voiceUrl = sound.sourceUrl ?? "" + } else if type == 102, let pic = msg.pictureElem { + imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" + imageW = msgImageW + imageH = msgImageH + } else if type == 106, let at = msg.atTextElem { + content = at.text ?? "" + atUserIDs = at.atUserList ?? [] + atNicknames = (at.atUsersInfo ?? []).compactMap { $0.groupNickname } + isAtAll = at.isAtAll + if let q = at.quoteMessage { + quotePreview = makeQuotePreview(from: q) + } + } else if type == 114, let q = msg.quoteElem { + content = q.text ?? "" + if let quoted = q.quoteMessage { + quotePreview = makeQuotePreview(from: quoted) + } + } else if let sound = msg.soundElem { content = "\(sound.duration)" voiceUrl = sound.sourceUrl ?? "" - imageUrl = "" - imageW = 0; imageH = 0 } else if let pic = msg.pictureElem { - content = "" - voiceUrl = "" imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" imageW = msgImageW imageH = msgImageH } else { content = msg.textElem?.content ?? "" - voiceUrl = "" - imageUrl = "" - imageW = 0; imageH = 0 } let sendID = msg.sendID ?? "" @@ -336,37 +696,81 @@ final class GroupChatViewModel { imageWidth: imageW, imageHeight: imageH, timestamp: ts, - showTime: false + showTime: false, + quotePreview: quotePreview, + atUserIDs: atUserIDs, + atNicknames: atNicknames, + isAtAll: isAtAll ) } - // emoji pattern: js_emoji:数字 - private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: []) + private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview { + let id = msg.clientMsgID + let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "") + if msg.status.rawValue == OIMMessageStatus.revoke.rawValue || msg.contentType.rawValue == 2101 { + return QuotePreview(senderName: name, kind: .unknown, summary: "引用内容已撤回", quotedClientMsgID: id) + } + let type = msg.contentType.rawValue + if type == 102 { + return QuotePreview(senderName: name, kind: .image, summary: "[图片]", quotedClientMsgID: id) + } + if type == 103 { + let dur = (msg.soundElem?.duration ?? 0) / 1000 + let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]" + return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id) + } + var text = "" + if type == 106 { + text = msg.atTextElem?.text ?? "" + } else if type == 114 { + text = msg.quoteElem?.text ?? "" + } else { + text = msg.textElem?.content ?? "" + } + if let pattern = emojiPattern, + pattern.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) != nil { + return QuotePreview(senderName: name, kind: .emoji, summary: "[表情]", quotedClientMsgID: id) + } + if text.isEmpty { + return QuotePreview(senderName: name, kind: .unknown, summary: "[消息]", quotedClientMsgID: id) + } + let truncated = text.count > 40 ? String(text.prefix(40)) + "…" : text + return QuotePreview(senderName: name, kind: .text, summary: truncated, quotedClientMsgID: id) + } private func toSectionItem(_ msg: OIMMessageInfo) -> ChatSectionItem? { - // 通知消息 + if msg.contentType.rawValue == 2101 || msg.status.rawValue == OIMMessageStatus.revoke.rawValue { + let ts = TimeInterval(msg.sendTime) / 1000.0 + let tip = revokeTip(revokerID: msg.sendID ?? "", + revokerNickname: msg.senderNickname ?? "") + return .revoked(tip, showTime: false, timestamp: ts) + } + if (msg.contentType.rawValue == 1501 || msg.contentType.rawValue == 1510 || msg.contentType.rawValue == 1520), let noti = msg.notificationElem, let text = parseNotification(noti, contentType: msg.contentType.rawValue) { let ts = TimeInterval(msg.sendTime) / 1000.0 return .notification(text, showTime: false, timestamp: ts) } - // 语音消息 + + // 语音(104 video 沿用历史兼容) if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 { let chatMsg = convert(msg) return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg) } - // 图片消息 + if msg.contentType.rawValue == 102 { let chatMsg = convert(msg) return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg) } - // 普通文本消息 + + // 文本 / @ / 引用 let chatMsg = convert(msg) - if chatMsg.content.isEmpty { return nil } - // 检测是否为纯 emoji 消息 + if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil } + if let pattern = emojiPattern, - pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil { + pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil, + chatMsg.quotePreview == nil { return chatMsg.isSelf ? .emojiSend(chatMsg) : .emojiReceived(chatMsg) } return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg) @@ -379,35 +783,27 @@ final class GroupChatViewModel { switch contentType { case 1501: - // 群创建通知(admin 创建) guard let opUser = json["opUser"] as? [String: Any] else { return nil } let groupID = opUser["groupID"] as? String ?? "" let isOwner = groupID.contains(AppContextManager.shared.userId) let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建" return NSAttributedString(string: text) - case 1510: - // 新成员加入通知 guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil } let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? "" - let text = "\(nickName) 加入了圈子" - return NSAttributedString(string: text) - + return NSAttributedString(string: "\(nickName) 加入了圈子") case 1520: - // 群名称改变通知 guard let opUser = json["opUser"] as? [String: Any] else { return nil } let opUserID = opUser["userID"] as? String ?? "" let opNickName = getUserNickName(id: opUserID) let newName = group["groupName"] as? String ?? "" guard !newName.isEmpty else { return nil } - let tip = "\(opNickName) 将群名称修改为 " let result = NSMutableAttributedString(string: tip + newName) result.addAttribute(.font, value: UIFont.systemFont(ofSize: 12), range: NSRange(location: 0, length: result.length)) let nameRange = NSRange(location: tip.count, length: newName.utf16.count) result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange) return result - default: return nil } @@ -416,22 +812,24 @@ final class GroupChatViewModel { // MARK: - ChatSectionItem Helpers extension ChatSectionItem { - /// 提取 ChatMessage(仅用于有消息的 case) var chatMessage: ChatMessage? { switch self { case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): return m - case .notification: return nil + case .notification, .revoked: + return nil } } - /// 用给定 ChatMessage 重建 case + /// 撤回项可挂原 id 时用(当前 revoked 不存 id,定位走 chatMessage) + var revokedClientMsgID: String? { nil } + static func with(_ msg: ChatMessage) -> ChatSectionItem { if !msg.imageUrl.isEmpty { return msg.isSelf ? .imageSend(msg) : .imageReceived(msg) } - if msg.content.hasPrefix("js_emoji:") { + if msg.content.hasPrefix("js_emoji:"), msg.quotePreview == nil { return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg) } if !msg.voiceUrl.isEmpty {