// // 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 final class GroupChatViewModel { struct Input { let sendMessage: AnyObserver } struct Output { let messages: Observable<[ChatSectionItem]> let pendingQuoteSummary: Observable } let input: Input let output: Output private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: []) private let sendMessageSubject = PublishSubject() private let pendingQuoteSummarySubject = BehaviorRelay(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) } }