- IM消息引用、撤回
This commit is contained in:
parent
bd03abefc2
commit
38946b59af
|
|
@ -14,6 +14,7 @@ import AVFoundation
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import HXPHPicker
|
import HXPHPicker
|
||||||
import IQKeyboardManagerSwift
|
import IQKeyboardManagerSwift
|
||||||
|
import MJRefresh
|
||||||
|
|
||||||
final class GroupChatVC: BaseViewController {
|
final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
|
|
@ -22,6 +23,11 @@ final class GroupChatVC: BaseViewController {
|
||||||
fileprivate var rootView: GroupChatView!
|
fileprivate var rootView: GroupChatView!
|
||||||
private let viewModel = GroupChatViewModel()
|
private let viewModel = GroupChatViewModel()
|
||||||
private var msgListener: MessageListenerProxy?
|
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
|
// MARK: - Init
|
||||||
init(groupId: String) {
|
init(groupId: String) {
|
||||||
|
|
@ -46,6 +52,9 @@ final class GroupChatVC: BaseViewController {
|
||||||
setupMessageListener()
|
setupMessageListener()
|
||||||
setupVoiceRecording()
|
setupVoiceRecording()
|
||||||
setupPanelDismiss()
|
setupPanelDismiss()
|
||||||
|
setupChatLongPress()
|
||||||
|
setupMentionInput()
|
||||||
|
setupHistoryRefreshHeader()
|
||||||
|
|
||||||
// 并行加载:业务接口 + IM SDK 互不依赖,同时发起
|
// 并行加载:业务接口 + IM SDK 互不依赖,同时发起
|
||||||
requestGroupInfoByKey()
|
requestGroupInfoByKey()
|
||||||
|
|
@ -139,7 +148,33 @@ final class GroupChatVC: BaseViewController {
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.observe(on: MainScheduler.asyncInstance)
|
.observe(on: MainScheduler.asyncInstance)
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.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)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
|
@ -149,7 +184,58 @@ final class GroupChatVC: BaseViewController {
|
||||||
.disposed(by: disposeBag)
|
.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() {
|
private func scrollToBottom() {
|
||||||
let count = dataSource.sectionModels.first?.items.count ?? 0
|
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>> = {
|
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
|
||||||
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
||||||
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
|
|
@ -171,13 +282,157 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// MARK: - Message Listener
|
// MARK: - Message Listener
|
||||||
private func setupMessageListener() {
|
private func setupMessageListener() {
|
||||||
msgListener = MessageListenerProxy { [weak self] msg in
|
msgListener = MessageListenerProxy(
|
||||||
guard let self = self, msg.groupID == self.viewModel.groupId else { return }
|
onMessage: { [weak self] msg in
|
||||||
self.viewModel.onReceiveMessage(msg)
|
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!)
|
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
|
// MARK: - Actions
|
||||||
private func reactiveAction() {
|
private func reactiveAction() {
|
||||||
rootView.backBtn.rx.tap
|
rootView.backBtn.rx.tap
|
||||||
|
|
@ -267,16 +522,15 @@ final class GroupChatVC: BaseViewController {
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
let sendText = Observable.merge(
|
rootView.sendBtn.rx.tap
|
||||||
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
|
.subscribe(onNext: { [weak self] in
|
||||||
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
|
guard let self = self else { return }
|
||||||
.map { [weak self] _ in self?.rootView.textField.text ?? "" }
|
let text = self.rootView.textField.text ?? ""
|
||||||
)
|
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
self.viewModel.input.sendMessage.onNext(text)
|
||||||
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" })
|
self.rootView.textField.text = ""
|
||||||
|
self.hideMentionPicker()
|
||||||
sendText
|
})
|
||||||
.bind(to: viewModel.input.sendMessage)
|
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
// 设置
|
// 设置
|
||||||
|
|
@ -429,6 +683,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// Send voice message via OpenIM
|
// Send voice message via OpenIM
|
||||||
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
|
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
|
||||||
|
viewModel.cacheMessage(msg)
|
||||||
OIMManager.manager.sendMessage(msg,
|
OIMManager.manager.sendMessage(msg,
|
||||||
recvID: "",
|
recvID: "",
|
||||||
groupID: viewModel.groupId,
|
groupID: viewModel.groupId,
|
||||||
|
|
@ -456,15 +711,23 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// MARK: - dataSource
|
// MARK: - dataSource
|
||||||
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
|
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
|
||||||
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
|
RxTableViewSectionedReloadDataSource<ChatSectionModel> { [weak self] _, tableView, indexPath, item in
|
||||||
switch item {
|
switch item {
|
||||||
case let .send(msg):
|
case let .send(msg):
|
||||||
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
cell.configure(msg)
|
cell.configure(msg)
|
||||||
|
cell.onQuoteTap = { [weak self] in
|
||||||
|
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
|
||||||
|
self?.scrollToMessage(clientMsgID: id)
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .received(msg):
|
case let .received(msg):
|
||||||
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
cell.configure(msg)
|
cell.configure(msg)
|
||||||
|
cell.onQuoteTap = { [weak self] in
|
||||||
|
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
|
||||||
|
self?.scrollToMessage(clientMsgID: id)
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .emojiSend(msg):
|
case let .emojiSend(msg):
|
||||||
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
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)
|
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
||||||
}
|
}
|
||||||
return cell
|
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)
|
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
cell.configure(text, showTime: showTime, timestamp: timestamp)
|
cell.configure(text, showTime: showTime, timestamp: timestamp)
|
||||||
return cell
|
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
|
// MARK: - UITableViewDelegate
|
||||||
extension GroupChatVC: UITableViewDelegate {
|
extension GroupChatVC: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
|
@ -516,18 +807,32 @@ extension GroupChatVC: UITableViewDelegate {
|
||||||
(cell as? EmojiSendMsgCell)?.stopAnimation()
|
(cell as? EmojiSendMsgCell)?.stopAnimation()
|
||||||
(cell as? EmojiReceivedMsgCell)?.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
|
// MARK: - MessageListenerProxy
|
||||||
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
|
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) {
|
init(onMessage: @escaping (OIMMessageInfo) -> Void,
|
||||||
self.handler = handler
|
onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) {
|
||||||
|
self.onMessage = onMessage
|
||||||
|
self.onRevoked = onRevoked
|
||||||
}
|
}
|
||||||
|
|
||||||
func onRecvNewMessage(_ msg: OIMMessageInfo) {
|
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)
|
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
|
||||||
// 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重
|
// 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重
|
||||||
let localId = msg.clientMsgID ?? UUID().uuidString
|
let localId = msg.clientMsgID ?? UUID().uuidString
|
||||||
|
viewModel.cacheMessage(msg)
|
||||||
|
|
||||||
// 立即显示本地图片(带 loading)
|
// 立即显示本地图片(带 loading)
|
||||||
let localMsg = ChatMessage(
|
let localMsg = ChatMessage(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,63 @@ struct ChatMessage {
|
||||||
let timestamp: TimeInterval
|
let timestamp: TimeInterval
|
||||||
var showTime: Bool = false
|
var showTime: Bool = false
|
||||||
var isUploading: 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 {
|
class GroupChatView: UIView {
|
||||||
|
|
@ -45,6 +102,14 @@ class GroupChatView: UIView {
|
||||||
/// 语音录制回调
|
/// 语音录制回调
|
||||||
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
|
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
|
||||||
private var emojiPlaybackGeneration = 0
|
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() {
|
private func setupRx() {
|
||||||
|
|
||||||
|
|
@ -55,11 +120,13 @@ class GroupChatView: UIView {
|
||||||
let needsReset = !emojiPanelView.isHidden
|
let needsReset = !emojiPanelView.isHidden
|
||||||
|| !voiceRecordView.isHidden
|
|| !voiceRecordView.isHidden
|
||||||
|| textField.isFirstResponder
|
|| textField.isFirstResponder
|
||||||
|
|| !mentionPickerView.isHidden
|
||||||
|
|
||||||
guard needsReset else { return }
|
guard needsReset else { return }
|
||||||
stopVisibleEmojiAnimations()
|
stopVisibleEmojiAnimations()
|
||||||
emojiPanelView.isHidden = true
|
emojiPanelView.isHidden = true
|
||||||
voiceRecordView.isHidden = true
|
voiceRecordView.isHidden = true
|
||||||
|
mentionPickerView.isHidden = true
|
||||||
if !excludeTextField { textField.resignFirstResponder() }
|
if !excludeTextField { textField.resignFirstResponder() }
|
||||||
UIView.animate(withDuration: 0.25) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
|
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
|
||||||
|
|
@ -123,6 +190,8 @@ class GroupChatView: UIView {
|
||||||
|
|
||||||
addSubview(tableView)
|
addSubview(tableView)
|
||||||
addSubview(chatWarningView)
|
addSubview(chatWarningView)
|
||||||
|
addSubview(quoteBar)
|
||||||
|
addSubview(mentionPickerView)
|
||||||
addSubview(bottomBar)
|
addSubview(bottomBar)
|
||||||
addSubview(disableIMView)
|
addSubview(disableIMView)
|
||||||
bottomBar.addSubview(bottomBarCornerView)
|
bottomBar.addSubview(bottomBarCornerView)
|
||||||
|
|
@ -136,6 +205,11 @@ class GroupChatView: UIView {
|
||||||
bottomBar.addSubview(addBtn)
|
bottomBar.addSubview(addBtn)
|
||||||
bottomBar.addSubview(sendBtn)
|
bottomBar.addSubview(sendBtn)
|
||||||
|
|
||||||
|
quoteBar.addSubview(quoteBarLabel)
|
||||||
|
quoteBar.addSubview(quoteBarCloseBtn)
|
||||||
|
mentionPickerView.addSubview(mentionPickerContentView)
|
||||||
|
mentionPickerContentView.addSubview(mentionTableView)
|
||||||
|
|
||||||
navBgView.layoutChain
|
navBgView.layoutChain
|
||||||
.edges(excludingEdge: .bottom)
|
.edges(excludingEdge: .bottom)
|
||||||
.height(kNaviHeight)
|
.height(kNaviHeight)
|
||||||
|
|
@ -197,6 +271,28 @@ class GroupChatView: UIView {
|
||||||
.topToBottomOfView(navBarView)
|
.topToBottomOfView(navBarView)
|
||||||
.edgesHorzontal()
|
.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
|
bottomBar.layoutChain
|
||||||
.edgesHorzontal(15)
|
.edgesHorzontal(15)
|
||||||
.height(50)
|
.height(50)
|
||||||
|
|
@ -395,6 +491,63 @@ class GroupChatView: UIView {
|
||||||
return tv
|
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
|
// MARK: - Bottom Bar
|
||||||
lazy var bottomBar: UIView = {
|
lazy var bottomBar: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
|
|
@ -537,33 +690,234 @@ class GroupChatView: UIView {
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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
|
// MARK: - 发送的消息cell
|
||||||
class TextSendMsgCell: UITableViewCell {
|
class TextSendMsgCell: UITableViewCell {
|
||||||
|
|
||||||
|
var onQuoteTap: (() -> Void)?
|
||||||
|
private(set) var messageId: String = ""
|
||||||
|
var menuAnchorView: UIView { bubbleView }
|
||||||
|
|
||||||
func configure(_ msg: ChatMessage) {
|
func configure(_ msg: ChatMessage) {
|
||||||
|
messageId = msg.id
|
||||||
timeLabel.isHidden = !msg.showTime
|
timeLabel.isHidden = !msg.showTime
|
||||||
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
|
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
|
||||||
avatarView.image = msg.avatar
|
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 {
|
func flashHighlight() {
|
||||||
let date = Date(timeIntervalSince1970: t)
|
let old = contentView.backgroundColor
|
||||||
let now = Date()
|
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
|
||||||
let calendar = Calendar.current
|
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
|
||||||
let f = DateFormatter()
|
self.contentView.backgroundColor = old
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
|
|
@ -574,7 +928,7 @@ class TextSendMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let avatarView: UIImageView = {
|
let avatarView: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 15
|
iv.cornerRadius = 15
|
||||||
|
|
@ -598,77 +952,99 @@ class TextSendMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private let quoteBlock = ChatQuoteBlockView()
|
||||||
|
private var quoteHeightConstraint: NSLayoutConstraint?
|
||||||
|
private var quoteTopConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
contentView.addSubview(timeLabel)
|
||||||
contentView.addSubview(bubbleView)
|
contentView.addSubview(bubbleView)
|
||||||
bubbleView.addSubview(contentLabel)
|
bubbleView.addSubview(contentLabel)
|
||||||
contentView.addSubview(timeLabel)
|
contentView.addSubview(quoteBlock)
|
||||||
contentView.addSubview(avatarView)
|
contentView.addSubview(avatarView)
|
||||||
|
|
||||||
timeLabel.layoutChain
|
timeLabel.layoutChain.top().centerX()
|
||||||
.top()
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
avatarView.layoutChain
|
avatarView.layoutChain
|
||||||
.topToBottomOfView(timeLabel, offset: 14)
|
.topToBottomOfView(timeLabel, offset: 14)
|
||||||
.right(12)
|
.right(12).width(30).height(30)
|
||||||
.width(30).height(30)
|
|
||||||
|
|
||||||
bubbleView.layoutChain
|
bubbleView.layoutChain
|
||||||
.topToBottomOfView(avatarView, offset: -15)
|
.topToBottomOfView(avatarView, offset: -15)
|
||||||
.rightToView(avatarView, offset: -13)
|
.rightToView(avatarView, offset: -13)
|
||||||
.left(60, relation: .greaterThanOrEqual)
|
.left(60, relation: .greaterThanOrEqual)
|
||||||
.width(100, relation: .greaterThanOrEqual)
|
.width(100, relation: .greaterThanOrEqual)
|
||||||
.height(30, relation: .greaterThanOrEqual)
|
.height(30, relation: .greaterThanOrEqual)
|
||||||
.bottom(10)
|
|
||||||
|
|
||||||
contentLabel.layoutChain
|
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.edgesVertical(10)
|
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.edgesHorzontal(20)
|
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) {
|
@objc private func handleQuoteTap() { onQuoteTap?() }
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
bubbleView.setNeedsLayout()
|
bubbleView.setNeedsLayout()
|
||||||
bubbleView.layoutIfNeeded()
|
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))
|
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//MARK: - 收到的消息cell
|
// MARK: - 收到的消息cell
|
||||||
class TextReceivedMsgCell: UITableViewCell {
|
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) {
|
func configure(_ msg: ChatMessage) {
|
||||||
|
messageId = msg.id
|
||||||
|
senderId = msg.senderId
|
||||||
|
senderName = msg.senderName
|
||||||
timeLabel.isHidden = !msg.showTime
|
timeLabel.isHidden = !msg.showTime
|
||||||
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
|
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
|
||||||
avatarView.image = msg.avatar
|
avatarView.image = msg.avatar
|
||||||
nameLabel.text = msg.senderName
|
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 {
|
func flashHighlight() {
|
||||||
let date = Date(timeIntervalSince1970: t)
|
let old = contentView.backgroundColor
|
||||||
let now = Date()
|
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
|
||||||
let calendar = Calendar.current
|
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
|
||||||
let f = DateFormatter()
|
self.contentView.backgroundColor = old
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
|
|
@ -686,7 +1062,7 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let avatarView: UIImageView = {
|
let avatarView: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 15
|
iv.cornerRadius = 15
|
||||||
|
|
@ -694,6 +1070,7 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||||||
iv.borderWidth = 2
|
iv.borderWidth = 2
|
||||||
iv.borderColor = .white
|
iv.borderColor = .white
|
||||||
|
iv.isUserInteractionEnabled = true
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -710,6 +1087,10 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private let quoteBlock = ChatQuoteBlockView()
|
||||||
|
private var quoteHeightConstraint: NSLayoutConstraint?
|
||||||
|
private var quoteTopConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
|
|
@ -718,13 +1099,11 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
contentView.addSubview(timeLabel)
|
contentView.addSubview(timeLabel)
|
||||||
contentView.addSubview(bubbleView)
|
contentView.addSubview(bubbleView)
|
||||||
bubbleView.addSubview(contentLabel)
|
bubbleView.addSubview(contentLabel)
|
||||||
|
contentView.addSubview(quoteBlock)
|
||||||
contentView.addSubview(avatarView)
|
contentView.addSubview(avatarView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
|
|
||||||
timeLabel.layoutChain
|
timeLabel.layoutChain.top().centerX()
|
||||||
.top()
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
bubbleView.layoutChain
|
bubbleView.layoutChain
|
||||||
.topToBottomOfView(timeLabel, offset: 14)
|
.topToBottomOfView(timeLabel, offset: 14)
|
||||||
.left(30)
|
.left(30)
|
||||||
|
|
@ -732,30 +1111,49 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
.width(100, relation: .greaterThanOrEqual)
|
.width(100, relation: .greaterThanOrEqual)
|
||||||
.height(30, relation: .greaterThanOrEqual)
|
.height(30, relation: .greaterThanOrEqual)
|
||||||
|
|
||||||
avatarView.layoutChain
|
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.topToBottomOfView(bubbleView, offset: -15)
|
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.left(12)
|
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.width(30).height(30)
|
nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.bottom(10)
|
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
|
// 引用在 senderName 下方
|
||||||
.leftToRightOfView(avatarView, offset: 5)
|
let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4)
|
||||||
.bottomToView(avatarView)
|
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
|
||||||
|
quoteTopConstraint = qTop
|
||||||
contentLabel.layoutChain
|
quoteHeightConstraint = qH
|
||||||
.edgesVertical(10)
|
NSLayoutConstraint.activate([
|
||||||
.edgesHorzontal(20)
|
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) {
|
@objc private func handleQuoteTap() { onQuoteTap?() }
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
bubbleView.setNeedsLayout()
|
bubbleView.setNeedsLayout()
|
||||||
bubbleView.layoutIfNeeded()
|
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))
|
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -823,6 +1221,7 @@ final class NotificationMsgCell: UITableViewCell {
|
||||||
|
|
||||||
// MARK: - 发送的表情消息
|
// MARK: - 发送的表情消息
|
||||||
final class EmojiSendMsgCell: UITableViewCell {
|
final class EmojiSendMsgCell: UITableViewCell {
|
||||||
|
var menuAnchorView: UIView { lottieView }
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
@ -938,6 +1337,7 @@ final class EmojiSendMsgCell: UITableViewCell {
|
||||||
|
|
||||||
// MARK: - 收到的表情消息
|
// MARK: - 收到的表情消息
|
||||||
final class EmojiReceivedMsgCell: UITableViewCell {
|
final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
|
var menuAnchorView: UIView { lottieView }
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
@ -947,7 +1347,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let avatarView: UIImageView = {
|
let avatarView: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 15
|
iv.cornerRadius = 15
|
||||||
|
|
@ -955,6 +1355,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||||||
iv.borderWidth = 2
|
iv.borderWidth = 2
|
||||||
iv.borderColor = .white
|
iv.borderColor = .white
|
||||||
|
iv.isUserInteractionEnabled = true
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -1101,6 +1502,7 @@ extension GroupChatView: UICollectionViewDelegate {
|
||||||
|
|
||||||
// MARK: - 发送的语音消息
|
// MARK: - 发送的语音消息
|
||||||
final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
|
final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
|
var menuAnchorView: UIView { bubbleView }
|
||||||
|
|
||||||
private let timeLabel: UILabel = {
|
private let timeLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
@ -1238,6 +1640,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
|
|
||||||
// MARK: - 收到的语音消息
|
// MARK: - 收到的语音消息
|
||||||
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
|
var menuAnchorView: UIView { bubbleView }
|
||||||
|
|
||||||
func configure(_ msg: ChatMessage) {
|
func configure(_ msg: ChatMessage) {
|
||||||
timeLabel.isHidden = !msg.showTime
|
timeLabel.isHidden = !msg.showTime
|
||||||
|
|
@ -1289,7 +1692,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let avatarView: UIImageView = {
|
let avatarView: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 15
|
iv.cornerRadius = 15
|
||||||
|
|
@ -1297,6 +1700,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||||||
iv.borderWidth = 2
|
iv.borderWidth = 2
|
||||||
iv.borderColor = .white
|
iv.borderColor = .white
|
||||||
|
iv.isUserInteractionEnabled = true
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -1394,7 +1798,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
|
||||||
|
|
||||||
// MARK: - 发送的图片消息
|
// MARK: - 发送的图片消息
|
||||||
final class ImageSendMsgCell: UITableViewCell {
|
final class ImageSendMsgCell: UITableViewCell {
|
||||||
|
var menuAnchorView: UIView { photoView }
|
||||||
var onImageTap: (() -> Void)?
|
var onImageTap: (() -> Void)?
|
||||||
|
|
||||||
func configure(_ msg: ChatMessage) {
|
func configure(_ msg: ChatMessage) {
|
||||||
|
|
@ -1504,7 +1908,7 @@ final class ImageSendMsgCell: UITableViewCell {
|
||||||
|
|
||||||
// MARK: - 收到的图片消息
|
// MARK: - 收到的图片消息
|
||||||
final class ImageReceivedMsgCell: UITableViewCell {
|
final class ImageReceivedMsgCell: UITableViewCell {
|
||||||
|
var menuAnchorView: UIView { photoView }
|
||||||
var onImageTap: (() -> Void)?
|
var onImageTap: (() -> Void)?
|
||||||
|
|
||||||
func configure(_ msg: ChatMessage) {
|
func configure(_ msg: ChatMessage) {
|
||||||
|
|
@ -1549,7 +1953,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let avatarView: UIImageView = {
|
let avatarView: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 15
|
iv.cornerRadius = 15
|
||||||
|
|
@ -1557,6 +1961,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
|
||||||
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||||||
iv.borderWidth = 2
|
iv.borderWidth = 2
|
||||||
iv.borderColor = .white
|
iv.borderColor = .white
|
||||||
|
iv.isUserInteractionEnabled = true
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@ import RxCocoa
|
||||||
import OpenIMSDK
|
import OpenIMSDK
|
||||||
import Differentiator
|
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 {
|
enum ChatSectionItem {
|
||||||
case send(ChatMessage)
|
case send(ChatMessage)
|
||||||
case received(ChatMessage)
|
case received(ChatMessage)
|
||||||
|
|
@ -21,6 +32,7 @@ enum ChatSectionItem {
|
||||||
case imageSend(ChatMessage)
|
case imageSend(ChatMessage)
|
||||||
case imageReceived(ChatMessage)
|
case imageReceived(ChatMessage)
|
||||||
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
|
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
|
||||||
|
case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
|
typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
|
||||||
|
|
@ -33,6 +45,7 @@ final class GroupChatViewModel {
|
||||||
|
|
||||||
struct Output {
|
struct Output {
|
||||||
let messages: Observable<[ChatSectionItem]>
|
let messages: Observable<[ChatSectionItem]>
|
||||||
|
let pendingQuoteSummary: Observable<String?>
|
||||||
}
|
}
|
||||||
|
|
||||||
let input: Input
|
let input: Input
|
||||||
|
|
@ -40,22 +53,42 @@ final class GroupChatViewModel {
|
||||||
|
|
||||||
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
|
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
|
||||||
private let sendMessageSubject = PublishSubject<String>()
|
private let sendMessageSubject = PublishSubject<String>()
|
||||||
|
private let pendingQuoteSummarySubject = BehaviorRelay<String?>(value: nil)
|
||||||
private var lastTimeGap: TimeInterval = 0
|
private var lastTimeGap: TimeInterval = 0
|
||||||
/// 两条消息间隔超过此值(秒)显示时间戳
|
private let timeGapThreshold: TimeInterval = 300
|
||||||
private let timeGapThreshold: TimeInterval = 300 // 5 minutes
|
|
||||||
|
|
||||||
var groupModel: GroupInfoModel?
|
var groupModel: GroupInfoModel?
|
||||||
var memberList: [GroupMemberModel] = [] {
|
var memberList: [GroupMemberModel] = [] {
|
||||||
didSet {
|
didSet { buildAvatarCache() }
|
||||||
buildAvatarCache()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var groupId: String = ""
|
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
|
// MARK: - Init
|
||||||
init() {
|
init() {
|
||||||
input = Input(sendMessage: sendMessageSubject.asObserver())
|
input = Input(sendMessage: sendMessageSubject.asObserver())
|
||||||
output = Output(messages: messagesSubject.asObservable())
|
output = Output(
|
||||||
|
messages: messagesSubject.asObservable(),
|
||||||
|
pendingQuoteSummary: pendingQuoteSummarySubject.asObservable()
|
||||||
|
)
|
||||||
|
|
||||||
sendMessageSubject
|
sendMessageSubject
|
||||||
.subscribe(onNext: { [weak self] text in
|
.subscribe(onNext: { [weak self] text in
|
||||||
|
|
@ -64,54 +97,36 @@ final class GroupChatViewModel {
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let disposeBag = DisposeBag()
|
var currentMessages: [ChatSectionItem] { messagesSubject.value }
|
||||||
|
|
||||||
|
var conversationID: String { "sg_\(groupId)" }
|
||||||
|
|
||||||
// MARK: - Avatar
|
// MARK: - Avatar
|
||||||
private var avatarCache: [String: UIImage] = [:]
|
private var avatarCache: [String: UIImage] = [:]
|
||||||
|
|
||||||
/// memberList 更新后调用,预构建 userID → UIImage 映射,避免 30 条消息逐一遍历
|
|
||||||
func buildAvatarCache() {
|
func buildAvatarCache() {
|
||||||
var cache: [String: UIImage] = [:]
|
var cache: [String: UIImage] = [:]
|
||||||
for member in memberList {
|
for member in memberList {
|
||||||
cache[member.user_id] = member.userIcon
|
cache[member.user_id] = member.userIcon
|
||||||
}
|
}
|
||||||
avatarCache = cache
|
avatarCache = cache
|
||||||
// 刷新已有消息的头像(并行加载时 loadMessages 可能先于 memberList 完成)
|
|
||||||
refreshMessageAvatars()
|
refreshMessageAvatars()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用 avatarCache 刷新已有消息的头像(并行加载时 loadMessages 可能先于 memberList 完成)
|
|
||||||
private func refreshMessageAvatars() {
|
private func refreshMessageAvatars() {
|
||||||
var items = messagesSubject.value
|
var items = messagesSubject.value
|
||||||
var didChange = false
|
var didChange = false
|
||||||
items = items.map { item in
|
items = items.map { item in
|
||||||
switch item {
|
guard var m = item.chatMessage, updateAvatar(&m) else { return item }
|
||||||
case var .send(m): if updateAvatar(&m) { didChange = true }; return .send(m)
|
didChange = true
|
||||||
case var .received(m): if updateAvatar(&m) { didChange = true }; return .received(m)
|
return ChatSectionItem.with(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)
|
|
||||||
}
|
}
|
||||||
|
if didChange { messagesSubject.accept(items) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 尝试用缓存更新单条消息的头像,返回是否变更
|
|
||||||
private func updateAvatar(_ msg: inout ChatMessage) -> Bool {
|
private func updateAvatar(_ msg: inout ChatMessage) -> Bool {
|
||||||
guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false }
|
guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false }
|
||||||
msg = ChatMessage(
|
msg = msg.with(avatar: cached)
|
||||||
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
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,29 +144,359 @@ final class GroupChatViewModel {
|
||||||
memberList.first { id == $0.user_id }?.nick_name ?? ""
|
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
|
// MARK: - Send
|
||||||
private func onSendMessage(_ text: String) {
|
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,
|
OIMManager.manager.sendMessage(msg,
|
||||||
recvID: "",
|
recvID: "",
|
||||||
groupID: groupId,
|
groupID: groupId,
|
||||||
offlinePushInfo: nil,
|
offlinePushInfo: nil,
|
||||||
onSuccess: { [weak self] _ in
|
onSuccess: { [weak self] _ in
|
||||||
|
self?.cacheMessage(msg)
|
||||||
self?.appendMessage(msg)
|
self?.appendMessage(msg)
|
||||||
},
|
},
|
||||||
onProgress: nil as OIMNumberCallback?,
|
onProgress: nil as OIMNumberCallback?,
|
||||||
onFailure: { code, errMsg in
|
onFailure: { code, errMsg in
|
||||||
print("send failed: \(code) \(errMsg ?? "")")
|
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
|
// MARK: - Load
|
||||||
func loadMessages() {
|
func loadMessages() {
|
||||||
guard !groupId.isEmpty else { return }
|
guard !groupId.isEmpty else { return }
|
||||||
let param = OIMGetAdvancedHistoryMessageListParam()
|
let param = OIMGetAdvancedHistoryMessageListParam()
|
||||||
param.conversationID = "sg_\(groupId)"
|
param.conversationID = conversationID
|
||||||
param.count = 30
|
param.count = historyPageSize
|
||||||
|
|
||||||
OIMManager.manager.getAdvancedHistoryMessageList(
|
OIMManager.manager.getAdvancedHistoryMessageList(
|
||||||
param,
|
param,
|
||||||
|
|
@ -160,29 +505,27 @@ final class GroupChatViewModel {
|
||||||
let list = result?.messageList else { return }
|
let list = result?.messageList else { return }
|
||||||
var items: [ChatSectionItem] = []
|
var items: [ChatSectionItem] = []
|
||||||
for msg in list {
|
for msg in list {
|
||||||
|
self.cacheMessage(msg)
|
||||||
if let item = self.toSectionItem(msg) {
|
if let item = self.toSectionItem(msg) {
|
||||||
items.append(item)
|
items.append(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// messages come newest-first from API, sort by time ascending
|
|
||||||
items.sort {
|
items.sort {
|
||||||
let t1 = self.timestampFrom(item: $0)
|
self.timestampFrom(item: $0) < self.timestampFrom(item: $1)
|
||||||
let t2 = self.timestampFrom(item: $1)
|
|
||||||
return t1 < t2
|
|
||||||
}
|
}
|
||||||
// set showTime flag
|
|
||||||
for i in items.indices {
|
for i in items.indices {
|
||||||
let ts = self.timestampFrom(item: items[i])
|
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)
|
items[i] = self.setShowTime(items[i], true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let last = items.last {
|
if let last = items.last {
|
||||||
self.lastTimeGap = self.timestampFrom(item: last)
|
self.lastTimeGap = self.timestampFrom(item: last)
|
||||||
}
|
}
|
||||||
|
let isEnd = result?.isEnd ?? (list.count < self.historyPageSize)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
self.hasMoreHistory = !isEnd && !list.isEmpty
|
||||||
self.messagesSubject.accept(items)
|
self.messagesSubject.accept(items)
|
||||||
// 进入会话后清除未读
|
|
||||||
self.markAsRead()
|
self.markAsRead()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -191,35 +534,22 @@ final class GroupChatViewModel {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记当前会话为已读
|
|
||||||
private func markAsRead() {
|
private func markAsRead() {
|
||||||
let conversationID = "sg_\(groupId)"
|
|
||||||
OIMManager.manager.markConversationMessage(asRead: conversationID,
|
OIMManager.manager.markConversationMessage(asRead: conversationID,
|
||||||
onSuccess: nil,
|
onSuccess: nil,
|
||||||
onFailure: nil)
|
onFailure: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本地消息(发送中)
|
|
||||||
func appendLocalMessage(_ item: ChatSectionItem) {
|
func appendLocalMessage(_ item: ChatSectionItem) {
|
||||||
var items = messagesSubject.value
|
var items = messagesSubject.value
|
||||||
items.append(item)
|
items.append(item)
|
||||||
messagesSubject.accept(items)
|
messagesSubject.accept(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据 id 更新本地消息(图片上传成功/失败后替换本地占位消息)
|
|
||||||
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
|
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
|
||||||
var items = messagesSubject.value
|
var items = messagesSubject.value
|
||||||
guard let idx = items.firstIndex(where: { item in
|
guard let idx = items.firstIndex(where: { $0.chatMessage?.id == id }),
|
||||||
switch item {
|
var chatMsg = items[idx].chatMessage else { return }
|
||||||
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 }
|
|
||||||
update(&chatMsg)
|
update(&chatMsg)
|
||||||
items[idx] = ChatSectionItem.with(chatMsg)
|
items[idx] = ChatSectionItem.with(chatMsg)
|
||||||
messagesSubject.accept(items)
|
messagesSubject.accept(items)
|
||||||
|
|
@ -227,11 +557,11 @@ final class GroupChatViewModel {
|
||||||
|
|
||||||
// MARK: - Receive
|
// MARK: - Receive
|
||||||
func onReceiveMessage(_ msg: OIMMessageInfo) {
|
func onReceiveMessage(_ msg: OIMMessageInfo) {
|
||||||
|
cacheMessage(msg)
|
||||||
guard let item = toSectionItem(msg) else { return }
|
guard let item = toSectionItem(msg) else { return }
|
||||||
let ts = timestampFrom(item: item)
|
let ts = timestampFrom(item: item)
|
||||||
var items = messagesSubject.value
|
var items = messagesSubject.value
|
||||||
|
|
||||||
// 去重:如果 clientMsgID 已存在(本地占位消息),跳过监听器追加
|
|
||||||
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
|
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
|
||||||
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
|
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
|
||||||
return
|
return
|
||||||
|
|
@ -245,11 +575,15 @@ final class GroupChatViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Append sent message
|
|
||||||
private func appendMessage(_ msg: OIMMessageInfo) {
|
private func appendMessage(_ msg: OIMMessageInfo) {
|
||||||
|
cacheMessage(msg)
|
||||||
guard let item = toSectionItem(msg) else { return }
|
guard let item = toSectionItem(msg) else { return }
|
||||||
let ts = timestampFrom(item: item)
|
let ts = timestampFrom(item: item)
|
||||||
var items = messagesSubject.value
|
var items = messagesSubject.value
|
||||||
|
// 去重
|
||||||
|
if let id = msg.clientMsgID, items.contains(where: { $0.chatMessage?.id == id }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
|
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
|
||||||
lastTimeGap = ts
|
lastTimeGap = ts
|
||||||
items.append(showTime ? setShowTime(item, true) : item)
|
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 {
|
private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
|
||||||
switch item {
|
switch item {
|
||||||
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
|
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):
|
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
|
||||||
return m.timestamp
|
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 {
|
private func setShowTime(_ item: ChatSectionItem, _ show: Bool) -> ChatSectionItem {
|
||||||
switch item {
|
switch item {
|
||||||
case var .send(m): m.showTime = show; return .send(m)
|
case var .send(m): m.showTime = show; return .send(m)
|
||||||
case var .received(m): m.showTime = show; return .received(m)
|
case var .received(m): m.showTime = show; return .received(m)
|
||||||
case var .emojiSend(m): m.showTime = show; return .emojiSend(m)
|
case var .emojiSend(m): m.showTime = show; return .emojiSend(m)
|
||||||
case var .emojiReceived(m):m.showTime = show; return .emojiReceived(m)
|
case var .emojiReceived(m): m.showTime = show; return .emojiReceived(m)
|
||||||
case var .voiceSend(m): m.showTime = show; return .voiceSend(m)
|
case var .voiceSend(m): m.showTime = show; return .voiceSend(m)
|
||||||
case var .voiceReceived(m):m.showTime = show; return .voiceReceived(m)
|
case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
|
||||||
case var .imageSend(m): m.showTime = show; return .imageSend(m)
|
case var .imageSend(m): m.showTime = show; return .imageSend(m)
|
||||||
case var .imageReceived(m):m.showTime = show; return .imageReceived(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 .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 {
|
private func convert(_ msg: OIMMessageInfo) -> ChatMessage {
|
||||||
let isSelf = msg.isSelf()
|
let isSelf = msg.isSelf()
|
||||||
let ts = TimeInterval(msg.sendTime) / 1000.0
|
let ts = TimeInterval(msg.sendTime) / 1000.0
|
||||||
let content: String
|
var content = ""
|
||||||
let voiceUrl: String
|
var voiceUrl = ""
|
||||||
let imageUrl: String
|
var imageUrl = ""
|
||||||
let imageW: CGFloat
|
var imageW: CGFloat = 0
|
||||||
let imageH: CGFloat
|
var imageH: CGFloat = 0
|
||||||
|
var quotePreview: QuotePreview?
|
||||||
|
var atUserIDs: [String] = []
|
||||||
|
var atNicknames: [String] = []
|
||||||
|
var isAtAll = false
|
||||||
|
|
||||||
// 图片消息: 计算图片的显示宽高
|
|
||||||
let maxWH: CGFloat = 200
|
let maxWH: CGFloat = 200
|
||||||
var msgImageW: CGFloat = 0
|
var msgImageW: CGFloat = 0
|
||||||
var msgImageH: 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)"
|
content = "\(sound.duration)"
|
||||||
voiceUrl = sound.sourceUrl ?? ""
|
voiceUrl = sound.sourceUrl ?? ""
|
||||||
imageUrl = ""
|
|
||||||
imageW = 0; imageH = 0
|
|
||||||
} else if let pic = msg.pictureElem {
|
} else if let pic = msg.pictureElem {
|
||||||
content = ""
|
|
||||||
voiceUrl = ""
|
|
||||||
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
||||||
imageW = msgImageW
|
imageW = msgImageW
|
||||||
imageH = msgImageH
|
imageH = msgImageH
|
||||||
} else {
|
} else {
|
||||||
content = msg.textElem?.content ?? ""
|
content = msg.textElem?.content ?? ""
|
||||||
voiceUrl = ""
|
|
||||||
imageUrl = ""
|
|
||||||
imageW = 0; imageH = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sendID = msg.sendID ?? ""
|
let sendID = msg.sendID ?? ""
|
||||||
|
|
@ -336,37 +696,81 @@ final class GroupChatViewModel {
|
||||||
imageWidth: imageW,
|
imageWidth: imageW,
|
||||||
imageHeight: imageH,
|
imageHeight: imageH,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
showTime: false
|
showTime: false,
|
||||||
|
quotePreview: quotePreview,
|
||||||
|
atUserIDs: atUserIDs,
|
||||||
|
atNicknames: atNicknames,
|
||||||
|
isAtAll: isAtAll
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// emoji pattern: js_emoji:数字
|
private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
|
||||||
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
|
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? {
|
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),
|
if (msg.contentType.rawValue == 1501 || msg.contentType.rawValue == 1510 || msg.contentType.rawValue == 1520),
|
||||||
let noti = msg.notificationElem,
|
let noti = msg.notificationElem,
|
||||||
let text = parseNotification(noti, contentType: msg.contentType.rawValue) {
|
let text = parseNotification(noti, contentType: msg.contentType.rawValue) {
|
||||||
let ts = TimeInterval(msg.sendTime) / 1000.0
|
let ts = TimeInterval(msg.sendTime) / 1000.0
|
||||||
return .notification(text, showTime: false, timestamp: ts)
|
return .notification(text, showTime: false, timestamp: ts)
|
||||||
}
|
}
|
||||||
// 语音消息
|
|
||||||
|
// 语音(104 video 沿用历史兼容)
|
||||||
if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 {
|
if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 {
|
||||||
let chatMsg = convert(msg)
|
let chatMsg = convert(msg)
|
||||||
return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg)
|
return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg)
|
||||||
}
|
}
|
||||||
// 图片消息
|
|
||||||
if msg.contentType.rawValue == 102 {
|
if msg.contentType.rawValue == 102 {
|
||||||
let chatMsg = convert(msg)
|
let chatMsg = convert(msg)
|
||||||
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
|
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
|
||||||
}
|
}
|
||||||
// 普通文本消息
|
|
||||||
|
// 文本 / @ / 引用
|
||||||
let chatMsg = convert(msg)
|
let chatMsg = convert(msg)
|
||||||
if chatMsg.content.isEmpty { return nil }
|
if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
|
||||||
// 检测是否为纯 emoji 消息
|
|
||||||
if let pattern = emojiPattern,
|
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 ? .emojiSend(chatMsg) : .emojiReceived(chatMsg)
|
||||||
}
|
}
|
||||||
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
|
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
|
||||||
|
|
@ -379,35 +783,27 @@ final class GroupChatViewModel {
|
||||||
|
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case 1501:
|
case 1501:
|
||||||
// 群创建通知(admin 创建)
|
|
||||||
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
|
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
|
||||||
let groupID = opUser["groupID"] as? String ?? ""
|
let groupID = opUser["groupID"] as? String ?? ""
|
||||||
let isOwner = groupID.contains(AppContextManager.shared.userId)
|
let isOwner = groupID.contains(AppContextManager.shared.userId)
|
||||||
let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建"
|
let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建"
|
||||||
return NSAttributedString(string: text)
|
return NSAttributedString(string: text)
|
||||||
|
|
||||||
case 1510:
|
case 1510:
|
||||||
// 新成员加入通知
|
|
||||||
guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil }
|
guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil }
|
||||||
let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? ""
|
let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? ""
|
||||||
let text = "\(nickName) 加入了圈子"
|
return NSAttributedString(string: "\(nickName) 加入了圈子")
|
||||||
return NSAttributedString(string: text)
|
|
||||||
|
|
||||||
case 1520:
|
case 1520:
|
||||||
// 群名称改变通知
|
|
||||||
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
|
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
|
||||||
let opUserID = opUser["userID"] as? String ?? ""
|
let opUserID = opUser["userID"] as? String ?? ""
|
||||||
let opNickName = getUserNickName(id: opUserID)
|
let opNickName = getUserNickName(id: opUserID)
|
||||||
let newName = group["groupName"] as? String ?? ""
|
let newName = group["groupName"] as? String ?? ""
|
||||||
guard !newName.isEmpty else { return nil }
|
guard !newName.isEmpty else { return nil }
|
||||||
|
|
||||||
let tip = "\(opNickName) 将群名称修改为 "
|
let tip = "\(opNickName) 将群名称修改为 "
|
||||||
let result = NSMutableAttributedString(string: tip + newName)
|
let result = NSMutableAttributedString(string: tip + newName)
|
||||||
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 12), range: NSRange(location: 0, length: result.length))
|
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)
|
let nameRange = NSRange(location: tip.count, length: newName.utf16.count)
|
||||||
result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
|
result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -416,22 +812,24 @@ final class GroupChatViewModel {
|
||||||
|
|
||||||
// MARK: - ChatSectionItem Helpers
|
// MARK: - ChatSectionItem Helpers
|
||||||
extension ChatSectionItem {
|
extension ChatSectionItem {
|
||||||
/// 提取 ChatMessage(仅用于有消息的 case)
|
|
||||||
var chatMessage: ChatMessage? {
|
var chatMessage: ChatMessage? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
|
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):
|
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
|
||||||
return 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 {
|
static func with(_ msg: ChatMessage) -> ChatSectionItem {
|
||||||
if !msg.imageUrl.isEmpty {
|
if !msg.imageUrl.isEmpty {
|
||||||
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
|
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)
|
return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
|
||||||
}
|
}
|
||||||
if !msg.voiceUrl.isEmpty {
|
if !msg.voiceUrl.isEmpty {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue