jsdw_ios/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift

841 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}