- IM消息引用、撤回

This commit is contained in:
linshujie 2026-07-16 15:10:47 +08:00
parent bd03abefc2
commit 38946b59af
3 changed files with 1332 additions and 223 deletions

View File

@ -14,6 +14,7 @@ import AVFoundation
import AudioToolbox
import HXPHPicker
import IQKeyboardManagerSwift
import MJRefresh
final class GroupChatVC: BaseViewController {
@ -22,6 +23,11 @@ final class GroupChatVC: BaseViewController {
fileprivate var rootView: GroupChatView!
private let viewModel = GroupChatViewModel()
private var msgListener: MessageListenerProxy?
/// mention @ +
private var mentionRows: [(userId: String?, nickname: String)] = []
private var isMentionPickerVisible = false
///
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
// MARK: - Init
init(groupId: String) {
@ -46,6 +52,9 @@ final class GroupChatVC: BaseViewController {
setupMessageListener()
setupVoiceRecording()
setupPanelDismiss()
setupChatLongPress()
setupMentionInput()
setupHistoryRefreshHeader()
// + IM SDK
requestGroupInfoByKey()
@ -139,7 +148,33 @@ final class GroupChatVC: BaseViewController {
.skip(1)
.observe(on: MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] _ in
self?.scrollToBottom()
guard let self = self else { return }
if self.viewModel.suppressAutoScroll {
self.viewModel.suppressAutoScroll = false
self.restoreOffsetAfterPrependIfNeeded()
return
}
self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory
self.scrollToBottom()
})
.disposed(by: disposeBag)
viewModel.output.pendingQuoteSummary
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] summary in
guard let self = self else { return }
if let summary {
self.rootView.setQuoteBarVisible(true, summary: summary)
self.bringInputOverlaysToFront()
} else {
self.rootView.setQuoteBarVisible(false)
}
})
.disposed(by: disposeBag)
rootView.quoteBarCloseBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.viewModel.clearPendingQuote()
})
.disposed(by: disposeBag)
@ -149,7 +184,58 @@ final class GroupChatVC: BaseViewController {
.disposed(by: disposeBag)
}
private var hasScrolledToBottom = false
private func bringInputOverlaysToFront() {
rootView.bringSubviewToFront(rootView.mentionPickerView)
rootView.bringSubviewToFront(rootView.quoteBar)
rootView.bringSubviewToFront(rootView.bottomBar)
rootView.bringSubviewToFront(rootView.disableIMView)
}
// MARK: -
private func setupHistoryRefreshHeader() {
let header = MJRefreshNormalHeader { [weak self] in
self?.loadOlderHistoryFromHeader()
}
header.addFeedbackGenerator()
header.lastUpdatedTimeLabel?.isHidden = true
header.setTitle("下拉加载更早消息", for: .idle)
header.setTitle("松开加载", for: .pulling)
header.setTitle("加载中...", for: .refreshing)
rootView.tableView.mj_header = header
}
private func loadOlderHistoryFromHeader() {
guard viewModel.hasMoreHistory else {
rootView.tableView.mj_header?.endRefreshing()
rootView.tableView.mj_header?.isHidden = true
return
}
let tableView = rootView.tableView
pendingOffsetRestore = (tableView.contentSize.height, tableView.contentOffset.y)
viewModel.loadOlderHistory { [weak self] result in
guard let self = self else { return }
self.rootView.tableView.mj_header?.endRefreshing()
if !result.hasMore {
self.rootView.tableView.mj_header?.isHidden = true
}
// suppressAutoScroll restoreOffset prepend pending
if result.prependedCount == 0 {
self.pendingOffsetRestore = nil
}
}
}
private func restoreOffsetAfterPrependIfNeeded() {
guard let pending = pendingOffsetRestore else { return }
pendingOffsetRestore = nil
let tableView = rootView.tableView
tableView.layoutIfNeeded()
let delta = tableView.contentSize.height - pending.oldHeight
guard delta > 0 else { return }
var offset = tableView.contentOffset
offset.y = max(0, pending.oldOffset + delta)
tableView.setContentOffset(offset, animated: false)
}
private func scrollToBottom() {
let count = dataSource.sectionModels.first?.items.count ?? 0
@ -161,6 +247,31 @@ final class GroupChatVC: BaseViewController {
}
}
private func scrollToMessage(clientMsgID: String) {
viewModel.ensureMessageVisible(clientMsgID: clientMsgID) { [weak self] found in
guard let self = self else { return }
guard found, let indexPath = self.viewModel.indexPath(forClientMsgID: clientMsgID) else {
DLToast.showError(text: "无法定位到原消息")
return
}
self.rootView.tableView.layoutIfNeeded()
self.rootView.tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextSendMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextReceivedMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) {
let old = cell.contentView.backgroundColor
cell.contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
cell.contentView.backgroundColor = old
}
}
}
}
}
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
@ -171,13 +282,157 @@ final class GroupChatVC: BaseViewController {
// MARK: - Message Listener
private func setupMessageListener() {
msgListener = MessageListenerProxy { [weak self] msg in
msgListener = MessageListenerProxy(
onMessage: { [weak self] msg in
guard let self = self, msg.groupID == self.viewModel.groupId else { return }
self.viewModel.onReceiveMessage(msg)
},
onRevoked: { [weak self] info in
self?.viewModel.onMessageRevoked(info)
}
)
OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!)
}
// MARK: - Long press (quote / revoke / avatar @)
private func setupChatLongPress() {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleChatLongPress(_:)))
longPress.minimumPressDuration = 0.45
rootView.tableView.addGestureRecognizer(longPress)
}
@objc private func handleChatLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
let point = gesture.location(in: rootView.tableView)
guard let indexPath = rootView.tableView.indexPathForRow(at: point),
let cell = rootView.tableView.cellForRow(at: indexPath) else { return }
let items = viewModel.currentMessages
guard indexPath.row < items.count,
let msg = items[indexPath.row].chatMessage else { return }
// @
if !msg.isSelf, let avatar = avatarView(in: cell) {
let p = gesture.location(in: avatar)
if avatar.bounds.insetBy(dx: -6, dy: -6).contains(p) {
insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false)
return
}
}
showBubbleActionMenu(for: msg, from: cell)
}
private func avatarView(in cell: UITableViewCell) -> UIImageView? {
if let c = cell as? TextReceivedMsgCell { return c.avatarView }
if let c = cell as? EmojiReceivedMsgCell { return c.avatarView }
if let c = cell as? VoiceReceivedMsgCell { return c.avatarView }
if let c = cell as? ImageReceivedMsgCell { return c.avatarView }
return nil
}
private func menuAnchorView(in cell: UITableViewCell) -> UIView {
if let c = cell as? TextSendMsgCell { return c.menuAnchorView }
if let c = cell as? TextReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiSendMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceSendMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? ImageSendMsgCell { return c.menuAnchorView }
if let c = cell as? ImageReceivedMsgCell { return c.menuAnchorView }
return cell.contentView
}
private weak var bubbleMenu: ChatBubbleActionMenu?
private func showBubbleActionMenu(for msg: ChatMessage, from cell: UITableViewCell) {
bubbleMenu?.dismiss()
rootView.dismissAllPanels()
let menu = ChatBubbleActionMenu()
bubbleMenu = menu
menu.onQuote = { [weak self] in
guard let self = self,
let oim = self.viewModel.oimMessage(forClientMsgID: msg.id) else {
DLToast.showError(text: "无法引用该消息")
return
}
self.viewModel.setPendingQuote(oim)
self.rootView.textField.becomeFirstResponder()
}
menu.onRevoke = { [weak self] in
self?.viewModel.revokeMessage(clientMsgID: msg.id)
}
let anchor = menuAnchorView(in: cell)
// menu.show(in: rootView, anchor: anchor, showRevoke: msg.isSelf)
}
// MARK: - Mention
private func setupMentionInput() {
rootView.textField.delegate = self
rootView.mentionTableView.delegate = self
rootView.mentionTableView.dataSource = self
rootView.textField.rx.controlEvent(.editingChanged)
.subscribe(onNext: { [weak self] in
self?.handleMentionTrigger()
})
.disposed(by: disposeBag)
}
private func handleMentionTrigger() {
guard let text = rootView.textField.text,
let selected = rootView.textField.selectedTextRange else {
hideMentionPicker()
return
}
let cursor = rootView.textField.offset(from: rootView.textField.beginningOfDocument, to: selected.start)
let ns = text as NSString
let prefix = ns.substring(to: min(cursor, ns.length))
if prefix.hasSuffix("@") {
showMentionPicker()
} else if isMentionPickerVisible {
//
if prefix.last == " " || !prefix.contains("@") {
hideMentionPicker()
}
}
}
private func showMentionPicker() {
mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.nick_name) }
rootView.mentionTableView.reloadData()
rootView.mentionPickerView.isHidden = false
isMentionPickerVisible = true
bringInputOverlaysToFront()
rootView.layoutIfNeeded()
let bounds = rootView.mentionPickerView.bounds
rootView.mentionPickerView.layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}
private func hideMentionPicker() {
rootView.mentionPickerView.isHidden = true
isMentionPickerVisible = false
}
private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) {
hideMentionPicker()
let insert: String
if let userId {
insert = viewModel.mentionUser(userId: userId, nickname: nickname)
} else {
viewModel.setPendingAtAll(true)
insert = "@所有人 "
}
var current = rootView.textField.text ?? ""
if replaceTriggerAt, let atIdx = current.lastIndex(of: "@") {
let after = current[current.index(after: atIdx)...]
if after.isEmpty || !after.contains(where: { $0.isWhitespace }) {
current = String(current[..<atIdx])
}
}
rootView.textField.text = current + insert
rootView.textField.becomeFirstResponder()
}
// MARK: - Actions
private func reactiveAction() {
rootView.backBtn.rx.tap
@ -267,16 +522,15 @@ final class GroupChatVC: BaseViewController {
})
.disposed(by: disposeBag)
let sendText = Observable.merge(
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
.map { [weak self] _ in self?.rootView.textField.text ?? "" }
)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" })
sendText
.bind(to: viewModel.input.sendMessage)
rootView.sendBtn.rx.tap
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
let text = self.rootView.textField.text ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
self.viewModel.input.sendMessage.onNext(text)
self.rootView.textField.text = ""
self.hideMentionPicker()
})
.disposed(by: disposeBag)
//
@ -429,6 +683,7 @@ final class GroupChatVC: BaseViewController {
// Send voice message via OpenIM
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
viewModel.cacheMessage(msg)
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: viewModel.groupId,
@ -456,15 +711,23 @@ final class GroupChatVC: BaseViewController {
// MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
RxTableViewSectionedReloadDataSource<ChatSectionModel> { [weak self] _, tableView, indexPath, item in
switch item {
case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
@ -496,7 +759,8 @@ final class GroupChatVC: BaseViewController {
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .notification(text, showTime, timestamp):
case let .notification(text, showTime, timestamp),
let .revoked(text, showTime, timestamp):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text, showTime: showTime, timestamp: timestamp)
return cell
@ -505,6 +769,33 @@ final class GroupChatVC: BaseViewController {
}()
}
// MARK: - UITextFieldDelegate / Mention table
extension GroupChatVC: UITextFieldDelegate, UITableViewDataSource {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
let text = textField.text ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true }
viewModel.input.sendMessage.onNext(text)
textField.text = ""
hideMentionPicker()
return true
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableView === rootView.mentionTableView ? mentionRows.count : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MentionCell", for: indexPath)
let row = mentionRows[indexPath.row]
cell.textLabel?.font = .systemFont(ofSize: 14)
cell.textLabel?.text = row.userId == nil ? "@所有人" : "@\(row.nickname)"
cell.selectionStyle = .default
return cell
}
}
// MARK: - UITableViewDelegate
extension GroupChatVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
@ -516,18 +807,32 @@ extension GroupChatVC: UITableViewDelegate {
(cell as? EmojiSendMsgCell)?.stopAnimation()
(cell as? EmojiReceivedMsgCell)?.stopAnimation()
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard tableView === rootView.mentionTableView else { return }
tableView.deselectRow(at: indexPath, animated: true)
let row = mentionRows[indexPath.row]
insertMention(userId: row.userId, nickname: row.nickname)
}
}
// MARK: - MessageListenerProxy
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
private let handler: (OIMMessageInfo) -> Void
private let onMessage: (OIMMessageInfo) -> Void
private let onRevoked: (OIMMessageRevokedInfo) -> Void
init(handler: @escaping (OIMMessageInfo) -> Void) {
self.handler = handler
init(onMessage: @escaping (OIMMessageInfo) -> Void,
onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) {
self.onMessage = onMessage
self.onRevoked = onRevoked
}
func onRecvNewMessage(_ msg: OIMMessageInfo) {
handler(msg)
onMessage(msg)
}
func onRecvMessageRevoked(_ messageRevoked: OIMMessageRevokedInfo) {
onRevoked(messageRevoked)
}
}
@ -562,6 +867,7 @@ extension GroupChatVC: PhotoPickerControllerDelegate {
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
// 使 SDK clientMsgID ID便
let localId = msg.clientMsgID ?? UUID().uuidString
viewModel.cacheMessage(msg)
// loading
let localMsg = ChatMessage(

View File

@ -32,6 +32,63 @@ struct ChatMessage {
let timestamp: TimeInterval
var showTime: Bool = false
var isUploading: Bool = false
var quotePreview: QuotePreview? = nil
var atUserIDs: [String] = []
var atNicknames: [String] = []
var isAtAll: Bool = false
func with(avatar: UIImage? = nil,
showTime: Bool? = nil,
isUploading: Bool? = nil,
imageUrl: String? = nil,
quotePreview: QuotePreview? = nil) -> ChatMessage {
ChatMessage(
id: id,
isSelf: isSelf,
senderId: senderId,
avatar: avatar ?? self.avatar,
senderName: senderName,
content: content,
voiceUrl: voiceUrl,
imageUrl: imageUrl ?? self.imageUrl,
imageWidth: imageWidth,
imageHeight: imageHeight,
timestamp: timestamp,
showTime: showTime ?? self.showTime,
isUploading: isUploading ?? self.isUploading,
quotePreview: quotePreview ?? self.quotePreview,
atUserIDs: atUserIDs,
atNicknames: atNicknames,
isAtAll: isAtAll
)
}
/// attributed @
func attributedContent(isOutgoing: Bool) -> NSAttributedString {
let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333")
let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF")
let result = NSMutableAttributedString(
string: content,
attributes: [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: baseColor
]
)
var tokens = atNicknames.map { "@\($0)" }
if isAtAll { tokens.append("@所有人") }
for token in Set(tokens) where !token.isEmpty && token != "@" {
var searchRange = NSRange(location: 0, length: result.length)
while searchRange.location < result.length {
let found = (result.string as NSString).range(of: token, options: [], range: searchRange)
if found.location == NSNotFound { break }
result.addAttribute(.foregroundColor, value: atColor, range: found)
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 14, weight: .semibold), range: found)
let next = found.location + found.length
searchRange = NSRange(location: next, length: result.length - next)
}
}
return result
}
}
class GroupChatView: UIView {
@ -45,6 +102,14 @@ class GroupChatView: UIView {
///
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
private var emojiPlaybackGeneration = 0
private var quoteBarHeightConstraint: NSLayoutConstraint?
func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) {
quoteBar.isHidden = !visible
quoteBarHeightConstraint?.constant = visible ? 36 : 0
if let summary { quoteBarLabel.text = summary }
UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() }
}
private func setupRx() {
@ -55,11 +120,13 @@ class GroupChatView: UIView {
let needsReset = !emojiPanelView.isHidden
|| !voiceRecordView.isHidden
|| textField.isFirstResponder
|| !mentionPickerView.isHidden
guard needsReset else { return }
stopVisibleEmojiAnimations()
emojiPanelView.isHidden = true
voiceRecordView.isHidden = true
mentionPickerView.isHidden = true
if !excludeTextField { textField.resignFirstResponder() }
UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
@ -123,6 +190,8 @@ class GroupChatView: UIView {
addSubview(tableView)
addSubview(chatWarningView)
addSubview(quoteBar)
addSubview(mentionPickerView)
addSubview(bottomBar)
addSubview(disableIMView)
bottomBar.addSubview(bottomBarCornerView)
@ -136,6 +205,11 @@ class GroupChatView: UIView {
bottomBar.addSubview(addBtn)
bottomBar.addSubview(sendBtn)
quoteBar.addSubview(quoteBarLabel)
quoteBar.addSubview(quoteBarCloseBtn)
mentionPickerView.addSubview(mentionPickerContentView)
mentionPickerContentView.addSubview(mentionTableView)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
@ -197,6 +271,28 @@ class GroupChatView: UIView {
.topToBottomOfView(navBarView)
.edgesHorzontal()
quoteBar.layoutChain
.edgesHorzontal(15)
.bottomToTopOfView(bottomBar, offset: -6)
quoteBarHeightConstraint = quoteBar.heightAnchor.constraint(equalToConstant: 0)
quoteBarHeightConstraint?.isActive = true
quoteBarLabel.layoutChain
.left(12).rightToLeftOfView(quoteBarCloseBtn, offset: -8)
.centerY()
quoteBarCloseBtn.layoutChain
.right(8).centerY()
.width(24).height(24)
mentionPickerView.layoutChain
.edgesHorzontal(15)
.height(180)
.bottomToTopOfView(quoteBar, offset: -6)
mentionPickerContentView.layoutChain.edges()
mentionTableView.layoutChain.edges()
bottomBar.layoutChain
.edgesHorzontal(15)
.height(50)
@ -395,6 +491,63 @@ class GroupChatView: UIView {
return tv
}()
// MARK: - Quote / Mention
lazy var quoteBar: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#EEF6FB")
view.cornerRadius = 10
view.isHidden = true
return view
}()
lazy var quoteBarLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hexStr: "#666666")
label.lineBreakMode = .byTruncatingTail
return label
}()
lazy var quoteBarCloseBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#999999"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14)
return btn
}()
lazy var mentionPickerView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
// clipsToBounds
view.layer.shadowColor = UIColor(hexStr: "#0F2846").cgColor
view.layer.shadowOpacity = 0.18
view.layer.shadowRadius = 12
view.layer.shadowOffset = CGSize(width: 0, height: 4)
view.layer.masksToBounds = false
return view
}()
lazy var mentionPickerContentView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.layer.borderWidth = 0.5
view.layer.borderColor = UIColor(hexStr: "#E5E5E5").cgColor
view.clipsToBounds = true
return view
}()
lazy var mentionTableView: UITableView = {
let tv = UITableView(frame: .zero, style: .plain)
tv.separatorStyle = .singleLine
tv.rowHeight = 44
tv.backgroundColor = .white
tv.register(UITableViewCell.self, forCellReuseIdentifier: "MentionCell")
return tv
}()
// MARK: - Bottom Bar
lazy var bottomBar: UIView = {
let view = UIView()
@ -537,33 +690,234 @@ class GroupChatView: UIView {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
if !mentionPickerView.isHidden {
mentionPickerView.layer.shadowPath = UIBezierPath(
roundedRect: mentionPickerView.bounds,
cornerRadius: 12
).cgPath
}
}
}
// MARK: - Quote block
final class ChatQuoteBlockView: UIControl {
private let lineView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55)
return v
}()
private let label: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 11)
lab.textColor = UIColor(hexStr: "#666666")
lab.numberOfLines = 2
return lab
}()
override init(frame: CGRect) {
super.init(frame: frame)
cornerRadius = 4
backgroundColor = UIColor(hexStr: "#EEEEF0")
isHidden = true
// addSubview(lineView)
addSubview(label)
// lineView.layoutChain.left().top(2).bottom(2).width(2)
label.layoutChain.edgesHorzontal(10).edgesVertical()
// label.layoutChain.leftToRightOfView(lineView, offset: 6).right().top().bottom()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(_ preview: QuotePreview?) {
guard let preview else {
isHidden = true
return
}
isHidden = false
let name = preview.senderName.isEmpty ? "" : "\(preview.senderName): "
label.text = name + preview.summary
label.textColor = UIColor(hexStr: "#666666")
lineView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55)
}
}
// MARK: -
final class ChatBubbleActionMenu: UIView {
var onQuote: (() -> Void)?
var onRevoke: (() -> Void)?
var onDismiss: (() -> Void)?
private let panel = UIView()
private let arrow = UIView()
private let stack = UIStackView()
private let dimView = UIControl()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
dimView.backgroundColor = .clear
dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside)
addSubview(dimView)
dimView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimView.topAnchor.constraint(equalTo: topAnchor),
dimView.leadingAnchor.constraint(equalTo: leadingAnchor),
dimView.trailingAnchor.constraint(equalTo: trailingAnchor),
dimView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
panel.backgroundColor = UIColor(hexStr: "#333333")
panel.layer.cornerRadius = 10
panel.clipsToBounds = true
addSubview(panel)
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fill
stack.spacing = 0
panel.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: panel.topAnchor),
stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
panel.heightAnchor.constraint(equalToConstant: 44)
])
arrow.backgroundColor = UIColor(hexStr: "#333333")
addSubview(arrow)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show(in host: UIView, anchor: UIView, showRevoke: Bool) {
removeFromSuperview()
host.addSubview(self)
frame = host.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
stack.arrangedSubviews.forEach {
stack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote))
quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(quoteBtn)
if showRevoke {
let sep = UIView()
sep.backgroundColor = UIColor.white.withAlphaComponent(0.2)
sep.translatesAutoresizingMaskIntoConstraints = false
sep.widthAnchor.constraint(equalToConstant: 1).isActive = true
stack.addArrangedSubview(sep)
let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke))
revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(revokeBtn)
}
let panelW: CGFloat = showRevoke ? 145 : 72
let panelH: CGFloat = 44
let arrowSize: CGFloat = 8
let gap: CGFloat = 6
layoutIfNeeded()
let anchorRect = anchor.convert(anchor.bounds, to: host)
var panelX = anchorRect.midX - panelW / 2
panelX = max(12, min(panelX, host.bounds.width - panelW - 12))
var panelY = anchorRect.minY - gap - panelH - arrowSize
var arrowAbove = true
if panelY < host.safeAreaInsets.top + 8 {
panelY = anchorRect.maxY + gap + arrowSize
arrowAbove = false
}
panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
let arrowX = min(max(anchorRect.midX - arrowSize / 2, panelX + 10), panelX + panelW - arrowSize - 10)
if arrowAbove {
arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowSize, height: arrowSize)
} else {
arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowSize + 1, width: arrowSize, height: arrowSize)
}
arrow.transform = CGAffineTransform(rotationAngle: .pi / 4)
alpha = 0
UIView.animate(withDuration: 0.15) { self.alpha = 1 }
}
private func makeButton(title: String, action: Selector) -> UIButton {
let btn = UIButton(type: .system)
btn.setTitle(title, for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
btn.addTarget(self, action: action, for: .touchUpInside)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}
@objc private func handleQuote() {
onQuote?()
dismiss()
}
@objc private func handleRevoke() {
onRevoke?()
dismiss()
}
@objc private func handleDismiss() { dismiss() }
func dismiss() {
UIView.animate(withDuration: 0.12, animations: {
self.alpha = 0
}, completion: { _ in
self.onDismiss?()
self.removeFromSuperview()
})
}
}
private func chatFormatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
// MARK: - cell
class TextSendMsgCell: UITableViewCell {
var onQuoteTap: (() -> Void)?
private(set) var messageId: String = ""
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
messageId = msg.id
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
contentLabel.text = msg.content
contentLabel.attributedText = msg.attributedContent(isOutgoing: true)
let hasQuote = msg.quotePreview != nil
quoteBlock.configure(msg.quotePreview)
quoteHeightConstraint?.constant = hasQuote ? 28 : 0
quoteTopConstraint?.constant = hasQuote ? 4 : 0
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) {
f.dateFormat = "HH:mm"
} else if calendar.isDateInYesterday(date) {
f.dateFormat = "'昨天' HH:mm"
} else if calendar.isDate(date, equalTo: now, toGranularity: .year) {
f.dateFormat = "M-d HH:mm"
} else {
f.dateFormat = "yyyy-M-d HH:mm"
func flashHighlight() {
let old = contentView.backgroundColor
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
self.contentView.backgroundColor = old
}
return f.string(from: date)
}
private let timeLabel: UILabel = {
@ -574,7 +928,7 @@ class TextSendMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -598,77 +952,99 @@ class TextSendMsgCell: UITableViewCell {
return label
}()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel)
contentView.addSubview(timeLabel)
contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView)
timeLabel.layoutChain
.top()
.centerX()
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12)
.width(30).height(30)
.right(12).width(30).height(30)
bubbleView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13)
.left(60, relation: .greaterThanOrEqual)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
.bottom(10)
contentLabel.layoutChain
.edgesVertical(10)
.edgesHorzontal(20)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10)
])
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
quoteHeightConstraint = qH
NSLayoutConstraint.activate([
qTop,
quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor),
quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60),
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
qH
])
quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleQuoteTap() { onQuoteTap?() }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft ,.bottomLeft, .bottomRight],
bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
//MARK: - cell
// MARK: - cell
class TextReceivedMsgCell: UITableViewCell {
var onQuoteTap: (() -> Void)?
private(set) var messageId: String = ""
private(set) var senderId: String = ""
private(set) var senderName: String = ""
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
messageId = msg.id
senderId = msg.senderId
senderName = msg.senderName
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
contentLabel.text = msg.content
contentLabel.attributedText = msg.attributedContent(isOutgoing: false)
let hasQuote = msg.quotePreview != nil
quoteBlock.configure(msg.quotePreview)
quoteHeightConstraint?.constant = hasQuote ? 28 : 0
quoteTopConstraint?.constant = hasQuote ? 4 : 0
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) {
f.dateFormat = "HH:mm"
} else if calendar.isDateInYesterday(date) {
f.dateFormat = "'昨天' HH:mm"
} else if calendar.isDate(date, equalTo: now, toGranularity: .year) {
f.dateFormat = "M-d HH:mm"
} else {
f.dateFormat = "yyyy-M-d HH:mm"
func flashHighlight() {
let old = contentView.backgroundColor
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
self.contentView.backgroundColor = old
}
return f.string(from: date)
}
private let timeLabel: UILabel = {
@ -686,7 +1062,7 @@ class TextReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -694,6 +1070,7 @@ class TextReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -710,6 +1087,10 @@ class TextReceivedMsgCell: UITableViewCell {
return label
}()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
@ -718,13 +1099,11 @@ class TextReceivedMsgCell: UITableViewCell {
contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel)
contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView)
contentView.addSubview(nameLabel)
timeLabel.layoutChain
.top()
.centerX()
timeLabel.layoutChain.top().centerX()
bubbleView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.left(30)
@ -732,30 +1111,49 @@ class TextReceivedMsgCell: UITableViewCell {
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
avatarView.layoutChain
.topToBottomOfView(bubbleView, offset: -15)
.left(12)
.width(30).height(30)
.bottom(10)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
avatarView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10),
// +
avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15),
avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
avatarView.widthAnchor.constraint(equalToConstant: 30),
avatarView.heightAnchor.constraint(equalToConstant: 30),
nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5),
nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor)
])
nameLabel.layoutChain
.leftToRightOfView(avatarView, offset: 5)
.bottomToView(avatarView)
contentLabel.layoutChain
.edgesVertical(10)
.edgesHorzontal(20)
// senderName
let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4)
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
quoteHeightConstraint = qH
NSLayoutConstraint.activate([
qTop,
quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60),
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
qH
])
quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleQuoteTap() { onQuoteTap?() }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight],
bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
}
}
@ -823,6 +1221,7 @@ final class NotificationMsgCell: UITableViewCell {
// MARK: -
final class EmojiSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -938,6 +1337,7 @@ final class EmojiSendMsgCell: UITableViewCell {
// MARK: -
final class EmojiReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -947,7 +1347,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -955,6 +1355,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -1101,6 +1502,7 @@ extension GroupChatView: UICollectionViewDelegate {
// MARK: -
final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -1238,6 +1640,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: -
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
@ -1289,7 +1692,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -1297,6 +1700,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -1394,7 +1798,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: -
final class ImageSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
@ -1504,7 +1908,7 @@ final class ImageSendMsgCell: UITableViewCell {
// MARK: -
final class ImageReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
@ -1549,7 +1953,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -1557,6 +1961,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()

View File

@ -11,6 +11,17 @@ import RxCocoa
import OpenIMSDK
import Differentiator
enum QuoteKind {
case text, image, voice, emoji, unknown
}
struct QuotePreview {
let senderName: String
let kind: QuoteKind
let summary: String
let quotedClientMsgID: String?
}
enum ChatSectionItem {
case send(ChatMessage)
case received(ChatMessage)
@ -21,6 +32,7 @@ enum ChatSectionItem {
case imageSend(ChatMessage)
case imageReceived(ChatMessage)
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
}
typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
@ -33,6 +45,7 @@ final class GroupChatViewModel {
struct Output {
let messages: Observable<[ChatSectionItem]>
let pendingQuoteSummary: Observable<String?>
}
let input: Input
@ -40,22 +53,42 @@ final class GroupChatViewModel {
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
private let sendMessageSubject = PublishSubject<String>()
private let pendingQuoteSummarySubject = BehaviorRelay<String?>(value: nil)
private var lastTimeGap: TimeInterval = 0
///
private let timeGapThreshold: TimeInterval = 300 // 5 minutes
private let timeGapThreshold: TimeInterval = 300
var groupModel: GroupInfoModel?
var memberList: [GroupMemberModel] = [] {
didSet {
buildAvatarCache()
}
didSet { buildAvatarCache() }
}
var groupId: String = ""
///
private(set) var pendingQuote: OIMMessageInfo?
/// @
private(set) var pendingMentions: [(userId: String, nickname: String)] = []
private(set) var pendingAtAll: Bool = false
/// clientMsgID /
private(set) var messageCache: [String: OIMMessageInfo] = [:]
/// /
var suppressAutoScroll = false
///
private(set) var hasMoreHistory = true
private var isLoadingOlder = false
private let disposeBag = DisposeBag()
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
private let historyPageSize = 30
// MARK: - Init
init() {
input = Input(sendMessage: sendMessageSubject.asObserver())
output = Output(messages: messagesSubject.asObservable())
output = Output(
messages: messagesSubject.asObservable(),
pendingQuoteSummary: pendingQuoteSummarySubject.asObservable()
)
sendMessageSubject
.subscribe(onNext: { [weak self] text in
@ -64,54 +97,36 @@ final class GroupChatViewModel {
.disposed(by: disposeBag)
}
private let disposeBag = DisposeBag()
var currentMessages: [ChatSectionItem] { messagesSubject.value }
var conversationID: String { "sg_\(groupId)" }
// MARK: - Avatar
private var avatarCache: [String: UIImage] = [:]
/// memberList userID UIImage 30
func buildAvatarCache() {
var cache: [String: UIImage] = [:]
for member in memberList {
cache[member.user_id] = member.userIcon
}
avatarCache = cache
// loadMessages memberList
refreshMessageAvatars()
}
/// avatarCache loadMessages memberList
private func refreshMessageAvatars() {
var items = messagesSubject.value
var didChange = false
items = items.map { item in
switch item {
case var .send(m): if updateAvatar(&m) { didChange = true }; return .send(m)
case var .received(m): if updateAvatar(&m) { didChange = true }; return .received(m)
case var .emojiSend(m): if updateAvatar(&m) { didChange = true }; return .emojiSend(m)
case var .emojiReceived(m): if updateAvatar(&m) { didChange = true }; return .emojiReceived(m)
case var .voiceSend(m): if updateAvatar(&m) { didChange = true }; return .voiceSend(m)
case var .voiceReceived(m): if updateAvatar(&m) { didChange = true }; return .voiceReceived(m)
case var .imageSend(m): if updateAvatar(&m) { didChange = true }; return .imageSend(m)
case var .imageReceived(m): if updateAvatar(&m) { didChange = true }; return .imageReceived(m)
default: return item
}
}
if didChange {
messagesSubject.accept(items)
guard var m = item.chatMessage, updateAvatar(&m) else { return item }
didChange = true
return ChatSectionItem.with(m)
}
if didChange { messagesSubject.accept(items) }
}
///
private func updateAvatar(_ msg: inout ChatMessage) -> Bool {
guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false }
msg = ChatMessage(
id: msg.id, isSelf: msg.isSelf, senderId: msg.senderId,
avatar: cached, senderName: msg.senderName,
content: msg.content, voiceUrl: msg.voiceUrl, imageUrl: msg.imageUrl,
imageWidth: msg.imageWidth, imageHeight: msg.imageHeight,
timestamp: msg.timestamp, showTime: msg.showTime, isUploading: msg.isUploading
)
msg = msg.with(avatar: cached)
return true
}
@ -129,29 +144,359 @@ final class GroupChatViewModel {
memberList.first { id == $0.user_id }?.nick_name ?? ""
}
// MARK: - Quote / Mention state
func setPendingQuote(_ msg: OIMMessageInfo?) {
pendingQuote = msg
if let msg {
let preview = makeQuotePreview(from: msg)
let name = preview.senderName.isEmpty ? "消息" : preview.senderName
pendingQuoteSummarySubject.accept("\(name): \(preview.summary)")
} else {
pendingQuoteSummarySubject.accept(nil)
}
}
func clearPendingQuote() {
setPendingQuote(nil)
}
func mentionUser(userId: String, nickname: String) -> String {
let name = nickname.isEmpty ? getUserNickName(id: userId) : nickname
let display = name.isEmpty ? userId : name
if !pendingMentions.contains(where: { $0.userId == userId }) {
pendingMentions.append((userId, display))
}
return "@\(display) "
}
func setPendingAtAll(_ value: Bool) {
pendingAtAll = value
}
func clearPendingMentions() {
pendingMentions = []
pendingAtAll = false
}
func mentionCandidates(excludingSelf: Bool = true) -> [GroupMemberModel] {
let selfId = AppContextManager.shared.userId
return memberList.filter { !excludingSelf || $0.user_id != selfId }
}
func oimMessage(forClientMsgID id: String) -> OIMMessageInfo? {
messageCache[id]
}
// MARK: - Send
private func onSendMessage(_ text: String) {
let msg = OIMMessageInfo.createTextMessage(text)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let quote = pendingQuote
let atAll = pendingAtAll
let mentions = pendingMentions
let msg: OIMMessageInfo
if atAll {
msg = OIMMessageInfo.createText(atAllMessage: trimmed,
displayText: "@所有人",
message: quote)
} else if !mentions.isEmpty {
let ids = mentions.map(\.userId)
let infos: [OIMAtInfo] = mentions.map { m in
let info = OIMAtInfo()
info.atUserID = m.userId
info.groupNickname = m.nickname
return info
}
msg = OIMMessageInfo.createText(atMessage: trimmed,
atUsersID: ids,
atUsersInfo: infos,
message: quote)
} else if let quote {
msg = OIMMessageInfo.createQuoteMessage(trimmed, message: quote)
} else {
msg = OIMMessageInfo.createTextMessage(trimmed)
}
clearPendingQuote()
clearPendingMentions()
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: groupId,
offlinePushInfo: nil,
onSuccess: { [weak self] _ in
self?.cacheMessage(msg)
self?.appendMessage(msg)
},
onProgress: nil as OIMNumberCallback?,
onFailure: { code, errMsg in
print("send failed: \(code) \(errMsg ?? "")")
DispatchQueue.main.async {
DLToast.showError(text: errMsg ?? "发送失败")
}
})
}
// MARK: - Revoke
func revokeMessage(clientMsgID: String) {
OIMManager.manager.revokeMessage(conversationID,
clientMsgID: clientMsgID,
onSuccess: { [weak self] _ in
DispatchQueue.main.async {
self?.applyRevoke(clientMsgID: clientMsgID,
revokerID: AppContextManager.shared.userId,
revokerNickname: AppContextManager.shared.name)
}
}, onFailure: { code, errMsg in
DispatchQueue.main.async {
DLToast.showError(text: errMsg ?? "撤回失败(\(code))")
}
})
}
func onMessageRevoked(_ info: OIMMessageRevokedInfo) {
applyRevoke(clientMsgID: info.clientMsgID,
revokerID: info.revokerID,
revokerNickname: info.revokerNickname)
}
private func applyRevoke(clientMsgID: String, revokerID: String, revokerNickname: String) {
if pendingQuote?.clientMsgID == clientMsgID {
clearPendingQuote()
}
messageCache.removeValue(forKey: clientMsgID)
var items = messagesSubject.value
guard let idx = items.firstIndex(where: { $0.chatMessage?.id == clientMsgID || $0.revokedClientMsgID == clientMsgID }) else {
//
refreshQuotePreviewsAfterRevoke(clientMsgID)
return
}
let ts = timestampFrom(item: items[idx])
let showTime: Bool = {
switch items[idx] {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m.showTime
case let .notification(_, s, _), let .revoked(_, s, _):
return s
}
}()
let tip = revokeTip(revokerID: revokerID, revokerNickname: revokerNickname)
items[idx] = .revoked(tip, showTime: showTime, timestamp: ts)
messagesSubject.accept(items)
refreshQuotePreviewsAfterRevoke(clientMsgID)
}
private func revokeTip(revokerID: String, revokerNickname: String) -> NSAttributedString {
let isSelf = revokerID == AppContextManager.shared.userId
let text = isSelf ? "你撤回了一条消息" : "\(revokerNickname.isEmpty ? "对方" : revokerNickname) 撤回了一条消息"
return NSAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor(hexStr: "#999999")
])
}
private func refreshQuotePreviewsAfterRevoke(_ revokedId: String) {
var items = messagesSubject.value
var changed = false
for i in items.indices {
guard var m = items[i].chatMessage,
m.quotePreview?.quotedClientMsgID == revokedId else { continue }
m.quotePreview = QuotePreview(
senderName: m.quotePreview?.senderName ?? "",
kind: .unknown,
summary: "引用内容已撤回",
quotedClientMsgID: revokedId
)
items[i] = ChatSectionItem.with(m)
changed = true
}
if changed { messagesSubject.accept(items) }
}
// MARK: - Locate quoted message
func indexPath(forClientMsgID id: String) -> IndexPath? {
guard let idx = messagesSubject.value.firstIndex(where: { $0.chatMessage?.id == id }) else { return nil }
return IndexPath(row: idx, section: 0)
}
///
func ensureMessageVisible(clientMsgID: String, maxPages: Int = 5, completion: @escaping (Bool) -> Void) {
if indexPath(forClientMsgID: clientMsgID) != nil {
completion(true)
return
}
//
let param = OIMFindMessageListParam()
param.conversationID = conversationID
param.clientMsgIDList = [clientMsgID]
OIMManager.manager.findMessageList([param], onSuccess: { [weak self] result in
guard let self = self else { return }
let found = (result?.findResultItems ?? []).contains { item in
(item.messageList).contains { $0.clientMsgID == clientMsgID }
}
//
if !found {
if let cached = self.messageCache[clientMsgID],
cached.status.rawValue == OIMMessageStatus.revoke.rawValue || cached.contentType.rawValue == 2101 {
DispatchQueue.main.async { completion(false) }
return
}
// find SDK
}
self.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion)
}, onFailure: { [weak self] _, _ in
self?.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion)
})
}
///
struct LoadOlderResult {
let prependedCount: Int
let hasMore: Bool
}
/// UI
func loadOlderHistory(completion: ((LoadOlderResult) -> Void)? = nil) {
guard !isLoadingOlder else {
completion?(LoadOlderResult(prependedCount: 0, hasMore: hasMoreHistory))
return
}
guard hasMoreHistory else {
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
return
}
guard let oldest = oldestClientMsgID() else {
hasMoreHistory = false
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
return
}
isLoadingOlder = true
fetchOlderHistory(startClientMsgID: oldest) { [weak self] list, isEnd, success in
guard let self = self else { return }
self.isLoadingOlder = false
guard success else {
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: 0, hasMore: self.hasMoreHistory))
}
return
}
if list.isEmpty {
self.hasMoreHistory = false
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
}
return
}
let count = self.prependHistory(list)
if isEnd || count == 0 {
self.hasMoreHistory = false
}
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: count, hasMore: self.hasMoreHistory))
}
}
}
private func loadOlderPages(targeting clientMsgID: String, remaining: Int, completion: @escaping (Bool) -> Void) {
if indexPath(forClientMsgID: clientMsgID) != nil {
DispatchQueue.main.async { completion(true) }
return
}
guard remaining > 0, hasMoreHistory else {
DispatchQueue.main.async { completion(false) }
return
}
loadOlderHistory { [weak self] result in
guard let self = self else { return }
if self.indexPath(forClientMsgID: clientMsgID) != nil {
completion(true)
} else if result.prependedCount > 0, result.hasMore {
self.loadOlderPages(targeting: clientMsgID, remaining: remaining - 1, completion: completion)
} else {
completion(false)
}
}
}
private func oldestClientMsgID() -> String? {
for item in messagesSubject.value {
if let id = item.chatMessage?.id { return id }
}
return nil
}
private func fetchOlderHistory(startClientMsgID: String,
completion: @escaping ([OIMMessageInfo], Bool, Bool) -> Void) {
let param = OIMGetAdvancedHistoryMessageListParam()
param.conversationID = conversationID
param.count = historyPageSize
param.startClientMsgID = startClientMsgID
OIMManager.manager.getAdvancedHistoryMessageList(
param,
onSuccess: { [weak self] result in
let pageSize = self?.historyPageSize ?? 30
let list = result?.messageList ?? []
let isEnd = result?.isEnd ?? (list.isEmpty || list.count < pageSize)
completion(list, isEnd, true)
},
onFailure: { code, msg in
print("loadOlderHistory failed: \(code) \(msg ?? "")")
completion([], false, false)
})
}
///
@discardableResult
private func prependHistory(_ list: [OIMMessageInfo]) -> Int {
var newItems: [ChatSectionItem] = []
for msg in list {
cacheMessage(msg)
if let item = toSectionItem(msg) {
newItems.append(item)
}
}
newItems.sort { timestampFrom(item: $0) < timestampFrom(item: $1) }
let items = messagesSubject.value
let existingIDs = Set(items.compactMap { $0.chatMessage?.id })
newItems = newItems.filter { item in
guard let id = item.chatMessage?.id else { return true }
return !existingIDs.contains(id)
}
guard !newItems.isEmpty else { return 0 }
var result = newItems + items
for i in result.indices {
let ts = timestampFrom(item: result[i])
let show = i == 0 || ts - timestampFrom(item: result[i - 1]) >= timeGapThreshold
result[i] = setShowTime(result[i], show)
}
let count = newItems.count
// 便 contentSize offset
suppressAutoScroll = true
if Thread.isMainThread {
messagesSubject.accept(result)
} else {
DispatchQueue.main.sync {
self.messagesSubject.accept(result)
}
}
return count
}
// MARK: - Load
func loadMessages() {
guard !groupId.isEmpty else { return }
let param = OIMGetAdvancedHistoryMessageListParam()
param.conversationID = "sg_\(groupId)"
param.count = 30
param.conversationID = conversationID
param.count = historyPageSize
OIMManager.manager.getAdvancedHistoryMessageList(
param,
@ -160,29 +505,27 @@ final class GroupChatViewModel {
let list = result?.messageList else { return }
var items: [ChatSectionItem] = []
for msg in list {
self.cacheMessage(msg)
if let item = self.toSectionItem(msg) {
items.append(item)
}
}
// messages come newest-first from API, sort by time ascending
items.sort {
let t1 = self.timestampFrom(item: $0)
let t2 = self.timestampFrom(item: $1)
return t1 < t2
self.timestampFrom(item: $0) < self.timestampFrom(item: $1)
}
// set showTime flag
for i in items.indices {
let ts = self.timestampFrom(item: items[i])
if i == 0 || ts - self.timestampFrom(item: items[i-1]) >= self.timeGapThreshold {
if i == 0 || ts - self.timestampFrom(item: items[i - 1]) >= self.timeGapThreshold {
items[i] = self.setShowTime(items[i], true)
}
}
if let last = items.last {
self.lastTimeGap = self.timestampFrom(item: last)
}
let isEnd = result?.isEnd ?? (list.count < self.historyPageSize)
DispatchQueue.main.async {
self.hasMoreHistory = !isEnd && !list.isEmpty
self.messagesSubject.accept(items)
//
self.markAsRead()
}
},
@ -191,35 +534,22 @@ final class GroupChatViewModel {
})
}
///
private func markAsRead() {
let conversationID = "sg_\(groupId)"
OIMManager.manager.markConversationMessage(asRead: conversationID,
onSuccess: nil,
onFailure: nil)
}
///
func appendLocalMessage(_ item: ChatSectionItem) {
var items = messagesSubject.value
items.append(item)
messagesSubject.accept(items)
}
/// id /
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
var items = messagesSubject.value
guard let idx = items.firstIndex(where: { item in
switch item {
case let .imageSend(m): return m.id == id
case let .voiceSend(m): return m.id == id
case let .send(m): return m.id == id
case let .emojiSend(m): return m.id == id
default: return false
}
}),
var chatMsg = items[idx].chatMessage
else { return }
guard let idx = items.firstIndex(where: { $0.chatMessage?.id == id }),
var chatMsg = items[idx].chatMessage else { return }
update(&chatMsg)
items[idx] = ChatSectionItem.with(chatMsg)
messagesSubject.accept(items)
@ -227,11 +557,11 @@ final class GroupChatViewModel {
// MARK: - Receive
func onReceiveMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item)
var items = messagesSubject.value
// clientMsgID
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
return
@ -245,11 +575,15 @@ final class GroupChatViewModel {
}
}
// MARK: - Append sent message
private func appendMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item)
var items = messagesSubject.value
//
if let id = msg.clientMsgID, items.contains(where: { $0.chatMessage?.id == id }) {
return
}
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
lastTimeGap = ts
items.append(showTime ? setShowTime(item, true) : item)
@ -258,12 +592,20 @@ final class GroupChatViewModel {
}
}
func cacheMessage(_ msg: OIMMessageInfo) {
if let id = msg.clientMsgID, !id.isEmpty {
messageCache[id] = msg
}
}
// MARK: - Helpers
private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
switch item {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m.timestamp
case let .notification(_, _, ts): return ts
case let .notification(_, _, ts), let .revoked(_, _, ts):
return ts
}
}
@ -272,25 +614,29 @@ final class GroupChatViewModel {
case var .send(m): m.showTime = show; return .send(m)
case var .received(m): m.showTime = show; return .received(m)
case var .emojiSend(m): m.showTime = show; return .emojiSend(m)
case var .emojiReceived(m):m.showTime = show; return .emojiReceived(m)
case var .emojiReceived(m): m.showTime = show; return .emojiReceived(m)
case var .voiceSend(m): m.showTime = show; return .voiceSend(m)
case var .voiceReceived(m):m.showTime = show; return .voiceReceived(m)
case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
case var .imageSend(m): m.showTime = show; return .imageSend(m)
case var .imageReceived(m):m.showTime = show; return .imageReceived(m)
case var .imageReceived(m): m.showTime = show; return .imageReceived(m)
case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts)
case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts)
}
}
private func convert(_ msg: OIMMessageInfo) -> ChatMessage {
let isSelf = msg.isSelf()
let ts = TimeInterval(msg.sendTime) / 1000.0
let content: String
let voiceUrl: String
let imageUrl: String
let imageW: CGFloat
let imageH: CGFloat
var content = ""
var voiceUrl = ""
var imageUrl = ""
var imageW: CGFloat = 0
var imageH: CGFloat = 0
var quotePreview: QuotePreview?
var atUserIDs: [String] = []
var atNicknames: [String] = []
var isAtAll = false
// :
let maxWH: CGFloat = 200
var msgImageW: CGFloat = 0
var msgImageH: CGFloat = 0
@ -305,22 +651,36 @@ final class GroupChatViewModel {
}
}
if let sound = msg.soundElem {
let type = msg.contentType.rawValue
if type == 103, let sound = msg.soundElem {
content = "\(sound.duration)"
voiceUrl = sound.sourceUrl ?? ""
} else if type == 102, let pic = msg.pictureElem {
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW
imageH = msgImageH
} else if type == 106, let at = msg.atTextElem {
content = at.text ?? ""
atUserIDs = at.atUserList ?? []
atNicknames = (at.atUsersInfo ?? []).compactMap { $0.groupNickname }
isAtAll = at.isAtAll
if let q = at.quoteMessage {
quotePreview = makeQuotePreview(from: q)
}
} else if type == 114, let q = msg.quoteElem {
content = q.text ?? ""
if let quoted = q.quoteMessage {
quotePreview = makeQuotePreview(from: quoted)
}
} else if let sound = msg.soundElem {
content = "\(sound.duration)"
voiceUrl = sound.sourceUrl ?? ""
imageUrl = ""
imageW = 0; imageH = 0
} else if let pic = msg.pictureElem {
content = ""
voiceUrl = ""
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW
imageH = msgImageH
} else {
content = msg.textElem?.content ?? ""
voiceUrl = ""
imageUrl = ""
imageW = 0; imageH = 0
}
let sendID = msg.sendID ?? ""
@ -336,37 +696,81 @@ final class GroupChatViewModel {
imageWidth: imageW,
imageHeight: imageH,
timestamp: ts,
showTime: false
showTime: false,
quotePreview: quotePreview,
atUserIDs: atUserIDs,
atNicknames: atNicknames,
isAtAll: isAtAll
)
}
// emoji pattern: js_emoji:
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
let id = msg.clientMsgID
let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "")
if msg.status.rawValue == OIMMessageStatus.revoke.rawValue || msg.contentType.rawValue == 2101 {
return QuotePreview(senderName: name, kind: .unknown, summary: "引用内容已撤回", quotedClientMsgID: id)
}
let type = msg.contentType.rawValue
if type == 102 {
return QuotePreview(senderName: name, kind: .image, summary: "[图片]", quotedClientMsgID: id)
}
if type == 103 {
let dur = (msg.soundElem?.duration ?? 0) / 1000
let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]"
return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id)
}
var text = ""
if type == 106 {
text = msg.atTextElem?.text ?? ""
} else if type == 114 {
text = msg.quoteElem?.text ?? ""
} else {
text = msg.textElem?.content ?? ""
}
if let pattern = emojiPattern,
pattern.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) != nil {
return QuotePreview(senderName: name, kind: .emoji, summary: "[表情]", quotedClientMsgID: id)
}
if text.isEmpty {
return QuotePreview(senderName: name, kind: .unknown, summary: "[消息]", quotedClientMsgID: id)
}
let truncated = text.count > 40 ? String(text.prefix(40)) + "" : text
return QuotePreview(senderName: name, kind: .text, summary: truncated, quotedClientMsgID: id)
}
private func toSectionItem(_ msg: OIMMessageInfo) -> ChatSectionItem? {
//
if msg.contentType.rawValue == 2101 || msg.status.rawValue == OIMMessageStatus.revoke.rawValue {
let ts = TimeInterval(msg.sendTime) / 1000.0
let tip = revokeTip(revokerID: msg.sendID ?? "",
revokerNickname: msg.senderNickname ?? "")
return .revoked(tip, showTime: false, timestamp: ts)
}
if (msg.contentType.rawValue == 1501 || msg.contentType.rawValue == 1510 || msg.contentType.rawValue == 1520),
let noti = msg.notificationElem,
let text = parseNotification(noti, contentType: msg.contentType.rawValue) {
let ts = TimeInterval(msg.sendTime) / 1000.0
return .notification(text, showTime: false, timestamp: ts)
}
//
// 104 video 沿
if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 {
let chatMsg = convert(msg)
return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg)
}
//
if msg.contentType.rawValue == 102 {
let chatMsg = convert(msg)
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
}
//
// / @ /
let chatMsg = convert(msg)
if chatMsg.content.isEmpty { return nil }
// emoji
if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
if let pattern = emojiPattern,
pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil {
pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil,
chatMsg.quotePreview == nil {
return chatMsg.isSelf ? .emojiSend(chatMsg) : .emojiReceived(chatMsg)
}
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
@ -379,35 +783,27 @@ final class GroupChatViewModel {
switch contentType {
case 1501:
// admin
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let groupID = opUser["groupID"] as? String ?? ""
let isOwner = groupID.contains(AppContextManager.shared.userId)
let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建"
return NSAttributedString(string: text)
case 1510:
//
guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil }
let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? ""
let text = "\(nickName) 加入了圈子"
return NSAttributedString(string: text)
return NSAttributedString(string: "\(nickName) 加入了圈子")
case 1520:
//
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let opUserID = opUser["userID"] as? String ?? ""
let opNickName = getUserNickName(id: opUserID)
let newName = group["groupName"] as? String ?? ""
guard !newName.isEmpty else { return nil }
let tip = "\(opNickName) 将群名称修改为 "
let result = NSMutableAttributedString(string: tip + newName)
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 12), range: NSRange(location: 0, length: result.length))
let nameRange = NSRange(location: tip.count, length: newName.utf16.count)
result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
return result
default:
return nil
}
@ -416,22 +812,24 @@ final class GroupChatViewModel {
// MARK: - ChatSectionItem Helpers
extension ChatSectionItem {
/// ChatMessage case
var chatMessage: ChatMessage? {
switch self {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m
case .notification: return nil
case .notification, .revoked:
return nil
}
}
/// ChatMessage case
/// id revoked id chatMessage
var revokedClientMsgID: String? { nil }
static func with(_ msg: ChatMessage) -> ChatSectionItem {
if !msg.imageUrl.isEmpty {
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
}
if msg.content.hasPrefix("js_emoji:") {
if msg.content.hasPrefix("js_emoji:"), msg.quotePreview == nil {
return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
}
if !msg.voiceUrl.isEmpty {