443 lines
17 KiB
Swift
443 lines
17 KiB
Swift
//
|
||
// GroupChatViewModel.swift
|
||
// QuickLocation
|
||
//
|
||
// Created by 八条 on 2026/6/5.
|
||
//
|
||
|
||
import UIKit
|
||
import RxSwift
|
||
import RxCocoa
|
||
import OpenIMSDK
|
||
import Differentiator
|
||
|
||
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)
|
||
}
|
||
|
||
typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
|
||
|
||
final class GroupChatViewModel {
|
||
|
||
struct Input {
|
||
let sendMessage: AnyObserver<String>
|
||
}
|
||
|
||
struct Output {
|
||
let messages: Observable<[ChatSectionItem]>
|
||
}
|
||
|
||
let input: Input
|
||
let output: Output
|
||
|
||
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
|
||
private let sendMessageSubject = PublishSubject<String>()
|
||
private var lastTimeGap: TimeInterval = 0
|
||
/// 两条消息间隔超过此值(秒)显示时间戳
|
||
private let timeGapThreshold: TimeInterval = 300 // 5 minutes
|
||
|
||
var groupModel: GroupInfoModel?
|
||
var memberList: [GroupMemberModel] = [] {
|
||
didSet {
|
||
buildAvatarCache()
|
||
}
|
||
}
|
||
var groupId: String = ""
|
||
|
||
// MARK: - Init
|
||
init() {
|
||
input = Input(sendMessage: sendMessageSubject.asObserver())
|
||
output = Output(messages: messagesSubject.asObservable())
|
||
|
||
sendMessageSubject
|
||
.subscribe(onNext: { [weak self] text in
|
||
self?.onSendMessage(text)
|
||
})
|
||
.disposed(by: disposeBag)
|
||
}
|
||
|
||
private let disposeBag = DisposeBag()
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// 尝试用缓存更新单条消息的头像,返回是否变更
|
||
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
|
||
)
|
||
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: - Send
|
||
private func onSendMessage(_ text: String) {
|
||
let msg = OIMMessageInfo.createTextMessage(text)
|
||
|
||
OIMManager.manager.sendMessage(msg,
|
||
recvID: "",
|
||
groupID: groupId,
|
||
offlinePushInfo: nil,
|
||
onSuccess: { [weak self] _ in
|
||
self?.appendMessage(msg)
|
||
},
|
||
onProgress: nil as OIMNumberCallback?,
|
||
onFailure: { code, errMsg in
|
||
print("send failed: \(code) \(errMsg ?? "")")
|
||
})
|
||
}
|
||
|
||
// MARK: - Load
|
||
func loadMessages() {
|
||
guard !groupId.isEmpty else { return }
|
||
let param = OIMGetAdvancedHistoryMessageListParam()
|
||
param.conversationID = "sg_\(groupId)"
|
||
param.count = 30
|
||
|
||
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 {
|
||
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
|
||
}
|
||
// 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 {
|
||
items[i] = self.setShowTime(items[i], true)
|
||
}
|
||
}
|
||
if let last = items.last {
|
||
self.lastTimeGap = self.timestampFrom(item: last)
|
||
}
|
||
DispatchQueue.main.async {
|
||
self.messagesSubject.accept(items)
|
||
// 进入会话后清除未读
|
||
self.markAsRead()
|
||
}
|
||
},
|
||
onFailure: { code, msg in
|
||
print("loadMessages failed: \(code) \(msg ?? "")")
|
||
})
|
||
}
|
||
|
||
/// 标记当前会话为已读
|
||
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 }
|
||
update(&chatMsg)
|
||
items[idx] = ChatSectionItem.with(chatMsg)
|
||
messagesSubject.accept(items)
|
||
}
|
||
|
||
// MARK: - Receive
|
||
func onReceiveMessage(_ msg: OIMMessageInfo) {
|
||
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
|
||
}
|
||
|
||
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
|
||
lastTimeGap = ts
|
||
items.append(showTime ? setShowTime(item, true) : item)
|
||
DispatchQueue.main.async {
|
||
self.messagesSubject.accept(items)
|
||
}
|
||
}
|
||
|
||
// MARK: - Append sent message
|
||
private func appendMessage(_ msg: OIMMessageInfo) {
|
||
guard let item = toSectionItem(msg) else { return }
|
||
let ts = timestampFrom(item: item)
|
||
var items = messagesSubject.value
|
||
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 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
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
// 图片消息: 计算图片的显示宽高
|
||
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)
|
||
}
|
||
}
|
||
|
||
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 ?? ""
|
||
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
|
||
)
|
||
}
|
||
|
||
// emoji pattern: js_emoji:数字
|
||
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
|
||
|
||
private func toSectionItem(_ msg: OIMMessageInfo) -> ChatSectionItem? {
|
||
// 通知消息
|
||
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)
|
||
}
|
||
// 语音消息
|
||
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 let pattern = emojiPattern,
|
||
pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != 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:
|
||
// 群创建通知(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)
|
||
|
||
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 {
|
||
/// 提取 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
|
||
}
|
||
}
|
||
|
||
/// 用给定 ChatMessage 重建 case
|
||
static func with(_ msg: ChatMessage) -> ChatSectionItem {
|
||
if !msg.imageUrl.isEmpty {
|
||
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
|
||
}
|
||
if msg.content.hasPrefix("js_emoji:") {
|
||
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)
|
||
}
|
||
}
|