- IM消息引用、撤回
This commit is contained in:
parent
bd03abefc2
commit
38946b59af
|
|
@ -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,6 +52,9 @@ final class GroupChatVC: BaseViewController {
|
|||
setupMessageListener()
|
||||
setupVoiceRecording()
|
||||
setupPanelDismiss()
|
||||
setupChatLongPress()
|
||||
setupMentionInput()
|
||||
setupHistoryRefreshHeader()
|
||||
|
||||
// 并行加载:业务接口 + IM SDK 互不依赖,同时发起
|
||||
requestGroupInfoByKey()
|
||||
|
|
@ -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<SectionModel<String, String>> = {
|
||||
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, 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[..<atIdx])
|
||||
}
|
||||
}
|
||||
rootView.textField.text = current + insert
|
||||
rootView.textField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
private func reactiveAction() {
|
||||
rootView.backBtn.rx.tap
|
||||
|
|
@ -267,16 +522,15 @@ final class GroupChatVC: BaseViewController {
|
|||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
let sendText = Observable.merge(
|
||||
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
|
||||
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
|
||||
.map { [weak self] _ in self?.rootView.textField.text ?? "" }
|
||||
)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" })
|
||||
|
||||
sendText
|
||||
.bind(to: viewModel.input.sendMessage)
|
||||
rootView.sendBtn.rx.tap
|
||||
.subscribe(onNext: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let text = self.rootView.textField.text ?? ""
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
self.viewModel.input.sendMessage.onNext(text)
|
||||
self.rootView.textField.text = ""
|
||||
self.hideMentionPicker()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
// 设置
|
||||
|
|
@ -429,6 +683,7 @@ final class GroupChatVC: BaseViewController {
|
|||
|
||||
// Send voice message via OpenIM
|
||||
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
|
||||
viewModel.cacheMessage(msg)
|
||||
OIMManager.manager.sendMessage(msg,
|
||||
recvID: "",
|
||||
groupID: viewModel.groupId,
|
||||
|
|
@ -456,15 +711,23 @@ final class GroupChatVC: BaseViewController {
|
|||
|
||||
// MARK: - dataSource
|
||||
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
|
||||
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
|
||||
RxTableViewSectionedReloadDataSource<ChatSectionModel> { [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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -197,6 +271,28 @@ class GroupChatView: UIView {
|
|||
.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)
|
||||
.height(50)
|
||||
|
|
@ -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,33 +690,234 @@ 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 = {
|
||||
|
|
@ -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
|
||||
|
|
@ -598,77 +952,99 @@ class TextSendMsgCell: UITableViewCell {
|
|||
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 = {
|
||||
|
|
@ -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
|
||||
}()
|
||||
|
||||
|
|
@ -710,6 +1087,10 @@ class TextReceivedMsgCell: UITableViewCell {
|
|||
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,13 +1099,11 @@ 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)
|
||||
|
|
@ -732,30 +1111,49 @@ class TextReceivedMsgCell: UITableViewCell {
|
|||
.width(100, relation: .greaterThanOrEqual)
|
||||
.height(30, relation: .greaterThanOrEqual)
|
||||
|
||||
avatarView.layoutChain
|
||||
.topToBottomOfView(bubbleView, offset: -15)
|
||||
.left(12)
|
||||
.width(30).height(30)
|
||||
.bottom(10)
|
||||
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)
|
||||
])
|
||||
|
||||
nameLabel.layoutChain
|
||||
.leftToRightOfView(avatarView, offset: 5)
|
||||
.bottomToView(avatarView)
|
||||
|
||||
contentLabel.layoutChain
|
||||
.edgesVertical(10)
|
||||
.edgesHorzontal(20)
|
||||
// 引用在 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
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, ChatSectionItem>
|
||||
|
|
@ -33,6 +45,7 @@ final class GroupChatViewModel {
|
|||
|
||||
struct Output {
|
||||
let messages: Observable<[ChatSectionItem]>
|
||||
let pendingQuoteSummary: Observable<String?>
|
||||
}
|
||||
|
||||
let input: Input
|
||||
|
|
@ -40,22 +53,42 @@ final class GroupChatViewModel {
|
|||
|
||||
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
|
||||
private let sendMessageSubject = PublishSubject<String>()
|
||||
private let pendingQuoteSummarySubject = BehaviorRelay<String?>(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
|
||||
|
|
@ -305,22 +651,36 @@ final class GroupChatViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue