841 lines
32 KiB
Swift
841 lines
32 KiB
Swift
//
|
||
// GroupChatViewModel.swift
|
||
// QuickLocation
|
||
//
|
||
// Created by 八条 on 2026/6/5.
|
||
//
|
||
|
||
import UIKit
|
||
import RxSwift
|
||
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)
|
||
case emojiSend(ChatMessage)
|
||
case emojiReceived(ChatMessage)
|
||
case voiceSend(ChatMessage)
|
||
case voiceReceived(ChatMessage)
|
||
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>
|
||
|
||
final class GroupChatViewModel {
|
||
|
||
struct Input {
|
||
let sendMessage: AnyObserver<String>
|
||
}
|
||
|
||
struct Output {
|
||
let messages: Observable<[ChatSectionItem]>
|
||
let pendingQuoteSummary: Observable<String?>
|
||
}
|
||
|
||
let input: Input
|
||
let output: Output
|
||
|
||
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
|
||
|
||
var groupModel: GroupInfoModel?
|
||
var memberList: [GroupMemberModel] = [] {
|
||
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(),
|
||
pendingQuoteSummary: pendingQuoteSummarySubject.asObservable()
|
||
)
|
||
|
||
sendMessageSubject
|
||
.subscribe(onNext: { [weak self] text in
|
||
self?.onSendMessage(text)
|
||
})
|
||
.disposed(by: disposeBag)
|
||
}
|
||
|
||
var currentMessages: [ChatSectionItem] { messagesSubject.value }
|
||
|
||
var conversationID: String { "sg_\(groupId)" }
|
||
|
||
// MARK: - Avatar
|
||
private var avatarCache: [String: UIImage] = [:]
|
||
|
||
func buildAvatarCache() {
|
||
var cache: [String: UIImage] = [:]
|
||
for member in memberList {
|
||
cache[member.user_id] = member.userIcon
|
||
}
|
||
avatarCache = cache
|
||
refreshMessageAvatars()
|
||
}
|
||
|
||
private func refreshMessageAvatars() {
|
||
var items = messagesSubject.value
|
||
var didChange = false
|
||
items = items.map { item in
|
||
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 = msg.with(avatar: cached)
|
||
return true
|
||
}
|
||
|
||
func getUserAvatar(id: String) -> UIImage {
|
||
if let image = avatarCache[id] { return image }
|
||
if let member = memberList.first(where: { $0.user_id == id }) {
|
||
let image = member.userIcon
|
||
avatarCache[id] = image
|
||
return image
|
||
}
|
||
return UIImage(named: "UserIcon/1") ?? UIImage()
|
||
}
|
||
|
||
func getUserNickName(id: String) -> String {
|
||
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 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 = conversationID
|
||
param.count = historyPageSize
|
||
|
||
OIMManager.manager.getAdvancedHistoryMessageList(
|
||
param,
|
||
onSuccess: { [weak self] result in
|
||
guard let self = self,
|
||
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)
|
||
}
|
||
}
|
||
items.sort {
|
||
self.timestampFrom(item: $0) < self.timestampFrom(item: $1)
|
||
}
|
||
for i in items.indices {
|
||
let ts = self.timestampFrom(item: items[i])
|
||
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()
|
||
}
|
||
},
|
||
onFailure: { code, msg in
|
||
print("loadMessages failed: \(code) \(msg ?? "")")
|
||
})
|
||
}
|
||
|
||
private func markAsRead() {
|
||
OIMManager.manager.markConversationMessage(asRead: conversationID,
|
||
onSuccess: nil,
|
||
onFailure: nil)
|
||
}
|
||
|
||
func appendLocalMessage(_ item: ChatSectionItem) {
|
||
var items = messagesSubject.value
|
||
items.append(item)
|
||
messagesSubject.accept(items)
|
||
}
|
||
|
||
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
|
||
var items = messagesSubject.value
|
||
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)
|
||
}
|
||
|
||
// MARK: - Receive
|
||
func onReceiveMessage(_ msg: OIMMessageInfo) {
|
||
cacheMessage(msg)
|
||
guard let item = toSectionItem(msg) else { return }
|
||
let ts = timestampFrom(item: item)
|
||
var items = messagesSubject.value
|
||
|
||
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
|
||
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
|
||
return
|
||
}
|
||
|
||
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
|
||
lastTimeGap = ts
|
||
items.append(showTime ? setShowTime(item, true) : item)
|
||
DispatchQueue.main.async {
|
||
self.messagesSubject.accept(items)
|
||
}
|
||
}
|
||
|
||
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)
|
||
DispatchQueue.main.async {
|
||
self.messagesSubject.accept(items)
|
||
}
|
||
}
|
||
|
||
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), let .revoked(_, _, ts):
|
||
return ts
|
||
}
|
||
}
|
||
|
||
private func setShowTime(_ item: ChatSectionItem, _ show: Bool) -> ChatSectionItem {
|
||
switch item {
|
||
case var .send(m): m.showTime = show; return .send(m)
|
||
case var .received(m): m.showTime = show; return .received(m)
|
||
case var .emojiSend(m): m.showTime = show; return .emojiSend(m)
|
||
case var .emojiReceived(m): m.showTime = show; return .emojiReceived(m)
|
||
case var .voiceSend(m): m.showTime = show; return .voiceSend(m)
|
||
case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
|
||
case var .imageSend(m): m.showTime = show; return .imageSend(m)
|
||
case var .imageReceived(m): m.showTime = show; return .imageReceived(m)
|
||
case 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
|
||
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
|
||
if let width = msg.pictureElem?.sourcePicture?.width,
|
||
let height = msg.pictureElem?.bigPicture?.height {
|
||
if width >= height {
|
||
msgImageW = maxWH
|
||
msgImageH = maxWH * CGFloat(height) / CGFloat(width)
|
||
} else {
|
||
msgImageH = maxWH
|
||
msgImageW = maxWH * CGFloat(width) / CGFloat(height)
|
||
}
|
||
}
|
||
|
||
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 ?? ""
|
||
} else if let pic = msg.pictureElem {
|
||
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
||
imageW = msgImageW
|
||
imageH = msgImageH
|
||
} else {
|
||
content = msg.textElem?.content ?? ""
|
||
}
|
||
|
||
let sendID = msg.sendID ?? ""
|
||
return ChatMessage(
|
||
id: msg.clientMsgID ?? UUID().uuidString,
|
||
isSelf: isSelf,
|
||
senderId: sendID,
|
||
avatar: getUserAvatar(id: sendID),
|
||
senderName: msg.senderNickname ?? "",
|
||
content: content,
|
||
voiceUrl: voiceUrl,
|
||
imageUrl: imageUrl,
|
||
imageWidth: imageW,
|
||
imageHeight: imageH,
|
||
timestamp: ts,
|
||
showTime: false,
|
||
quotePreview: quotePreview,
|
||
atUserIDs: atUserIDs,
|
||
atNicknames: atNicknames,
|
||
isAtAll: isAtAll
|
||
)
|
||
}
|
||
|
||
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 && 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,
|
||
chatMsg.quotePreview == nil {
|
||
return chatMsg.isSelf ? .emojiSend(chatMsg) : .emojiReceived(chatMsg)
|
||
}
|
||
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
|
||
}
|
||
|
||
private func parseNotification(_ elem: OIMNotificationElem, contentType: Int) -> NSAttributedString? {
|
||
guard let data = elem.detail?.data(using: .utf8),
|
||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let group = json["group"] as? [String: Any] else { return nil }
|
||
|
||
switch contentType {
|
||
case 1501:
|
||
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 ?? ""
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - ChatSectionItem Helpers
|
||
extension ChatSectionItem {
|
||
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, .revoked:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// 撤回项可挂原 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:"), msg.quotePreview == nil {
|
||
return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
|
||
}
|
||
if !msg.voiceUrl.isEmpty {
|
||
return msg.isSelf ? .voiceSend(msg) : .voiceReceived(msg)
|
||
}
|
||
return msg.isSelf ? .send(msg) : .received(msg)
|
||
}
|
||
}
|