348 lines
13 KiB
Swift
348 lines
13 KiB
Swift
//
|
|
// GroupChatVC.swift
|
|
// QuickLocation
|
|
//
|
|
// Created by 八条 on 2026/6/4.
|
|
//
|
|
|
|
import UIKit
|
|
import RxSwift
|
|
import RxCocoa
|
|
import RxDataSources
|
|
import OpenIMSDK
|
|
import AVFoundation
|
|
import AudioToolbox
|
|
|
|
final class GroupChatVC: BaseViewController {
|
|
|
|
override var isNavigationBarHidden: Bool { true }
|
|
|
|
fileprivate var rootView: GroupChatView!
|
|
private let viewModel = GroupChatViewModel()
|
|
private var msgListener: MessageListenerProxy?
|
|
|
|
// MARK: - Init
|
|
init(groupId: String) {
|
|
viewModel.groupId = groupId
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func loadView() {
|
|
rootView = GroupChatView(frame: UIScreen.main.bounds)
|
|
view = rootView
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
fd_interactivePopDisabled = true
|
|
bindViewModel()
|
|
requestGroupInfoByKey()
|
|
reactiveAction()
|
|
setupMessageListener()
|
|
setupVoiceRecording()
|
|
setupPanelDismiss()
|
|
}
|
|
|
|
private func setupPanelDismiss() {
|
|
// 用户开始拖拽 tableview 时收起面板
|
|
rootView.tableView.panGestureRecognizer.rx.event
|
|
.filter { $0.state == .began }
|
|
.subscribe(onNext: { [weak self] _ in
|
|
self?.rootView.dismissAllPanels()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
// 点击 cell 收起面板
|
|
rootView.tableView.rx.itemSelected
|
|
.subscribe(onNext: { [weak self] _ in
|
|
self?.rootView.dismissAllPanels()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
}
|
|
|
|
// MARK: - Bindings
|
|
private func bindViewModel() {
|
|
viewModel.output.messages
|
|
.skip(1)
|
|
.map { [ChatSectionModel(model: "", items: $0)] }
|
|
.observe(on: MainScheduler.asyncInstance)
|
|
.bind(to: rootView.tableView.rx.items(dataSource: dataSource))
|
|
.disposed(by: disposeBag)
|
|
|
|
viewModel.output.messages
|
|
.skip(1)
|
|
.observe(on: MainScheduler.asyncInstance)
|
|
.subscribe(onNext: { [weak self] _ in
|
|
self?.scrollToBottom()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
let emojiItems = UITableViewCell.emojiFileNames.map { $0 }
|
|
Observable.just([SectionModel(model: "", items: emojiItems)])
|
|
.bind(to: rootView.emojiCollectionView.rx.items(dataSource: emojiDataSource))
|
|
.disposed(by: disposeBag)
|
|
}
|
|
|
|
private var hasScrolledToBottom = false
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
if !hasScrolledToBottom {
|
|
hasScrolledToBottom = true
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
VoicePlayerManager.shared.stop()
|
|
}
|
|
|
|
private func scrollToBottom() {
|
|
let count = dataSource.sectionModels.first?.items.count ?? 0
|
|
guard count > 0 else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
self.rootView.tableView.layoutIfNeeded()
|
|
let indexPath = IndexPath(row: count - 1, section: 0)
|
|
self.rootView.tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
|
|
}
|
|
}
|
|
|
|
// MARK: - dataSource
|
|
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
|
|
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
|
|
switch item {
|
|
case let .send(msg):
|
|
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .received(msg):
|
|
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .emojiSend(msg):
|
|
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .emojiReceived(msg):
|
|
let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .voiceSend(msg):
|
|
let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .voiceReceived(msg):
|
|
let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .imageSend(msg):
|
|
let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .imageReceived(msg):
|
|
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(msg)
|
|
return cell
|
|
case let .notification(text):
|
|
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
|
|
cell.configure(text)
|
|
return cell
|
|
}
|
|
}
|
|
}()
|
|
|
|
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
|
|
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
|
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
|
if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
|
cell.configure(path: path)
|
|
}
|
|
return cell
|
|
}
|
|
}()
|
|
|
|
// MARK: - Message Listener
|
|
private func setupMessageListener() {
|
|
msgListener = MessageListenerProxy { [weak self] msg in
|
|
guard let self = self, msg.groupID == self.viewModel.groupId else { return }
|
|
self.viewModel.onReceiveMessage(msg)
|
|
}
|
|
OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
private func reactiveAction() {
|
|
rootView.backBtn.rx.tap
|
|
.subscribe(onNext: { _ in
|
|
AppRouter.shared.popOrDismiss()
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
rootView.emojiBtn.rx.tap
|
|
.subscribe(onNext: { [weak self] _ in
|
|
guard let self = self else { return }
|
|
let show = self.rootView.emojiPanelView.isHidden
|
|
self.rootView.emojiPanelView.isHidden = !show
|
|
let offset: CGFloat = show ? 220 : 0
|
|
UIView.animate(withDuration: 0.25) {
|
|
self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20 + offset)
|
|
} completion: { _ in
|
|
let offset: CGFloat = self.rootView.tableView.contentSize.height
|
|
self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false)
|
|
}
|
|
if show {
|
|
self.rootView.textField.resignFirstResponder()
|
|
EmojiPanelCell.preloadAnimations()
|
|
}
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
rootView.emojiCollectionView.rx.modelSelected(String.self)
|
|
.subscribe(onNext: { [weak self] name in
|
|
guard let self = self, let idx = UITableViewCell.emojiFileNames.firstIndex(of: name) else { return }
|
|
self.viewModel.input.sendMessage.onNext("js_emoji:\(idx)")
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
|
|
|
|
let sendText = Observable.merge(
|
|
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
|
|
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
|
|
.map { [weak self] _ in self?.rootView.textField.text ?? "" }
|
|
)
|
|
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" })
|
|
|
|
sendText
|
|
.bind(to: viewModel.input.sendMessage)
|
|
.disposed(by: disposeBag)
|
|
}
|
|
|
|
// MARK: - Voice Recording
|
|
private var audioRecorder: AVAudioRecorder?
|
|
private var recordFileURL: URL?
|
|
private var recordTimer: Timer?
|
|
private var recordDuration: Int = 0
|
|
private func setupVoiceRecording() {
|
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:)))
|
|
longPress.minimumPressDuration = 0.2
|
|
rootView.voiceRecordView.speakBtn.addGestureRecognizer(longPress)
|
|
}
|
|
|
|
@objc private func handleVoiceLongPress(_ gesture: UILongPressGestureRecognizer) {
|
|
let location = gesture.location(in: rootView.voiceRecordView)
|
|
let btnCenterX = rootView.voiceRecordView.speakBtn.center.x
|
|
let isCancel = location.x < btnCenterX - 100
|
|
|
|
switch gesture.state {
|
|
case .began:
|
|
AudioServicesPlaySystemSound(1519)
|
|
rootView.voiceRecordView.state = .recording
|
|
startRecording()
|
|
case .changed:
|
|
let wasRecording = rootView.voiceRecordView.state == .recording
|
|
rootView.voiceRecordView.state = isCancel ? .canceling : .recording
|
|
rootView.voiceRecordView.cancelBtn.isSelected = isCancel
|
|
if isCancel && wasRecording {
|
|
AudioServicesPlaySystemSound(1519)
|
|
}
|
|
case .ended:
|
|
stopRecording(cancel: isCancel)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func startRecording() {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try? session.setCategory(.playAndRecord, mode: .default)
|
|
try? session.setActive(true)
|
|
|
|
let dir = NSTemporaryDirectory()
|
|
let filename = "voice_\(Int(Date().timeIntervalSince1970)).wav"
|
|
recordFileURL = URL(fileURLWithPath: dir + filename)
|
|
|
|
let settings: [String: Any] = [
|
|
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
AVSampleRateKey: 8000,
|
|
AVNumberOfChannelsKey: 1,
|
|
AVLinearPCMBitDepthKey: 16,
|
|
AVLinearPCMIsFloatKey: false
|
|
]
|
|
|
|
guard let url = recordFileURL else { return }
|
|
audioRecorder = try? AVAudioRecorder(url: url, settings: settings)
|
|
audioRecorder?.record()
|
|
|
|
recordDuration = 0
|
|
recordTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
self?.recordDuration += 1
|
|
}
|
|
}
|
|
|
|
private func stopRecording(cancel: Bool) {
|
|
audioRecorder?.stop()
|
|
recordTimer?.invalidate()
|
|
recordTimer = nil
|
|
rootView.voiceRecordView.stopRotating()
|
|
|
|
try? AVAudioSession.sharedInstance().setActive(false)
|
|
|
|
if cancel {
|
|
self.rootView.voiceRecordView.cancelBtn.isSelected = false
|
|
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
|
|
return
|
|
}
|
|
|
|
guard let url = recordFileURL, recordDuration >= 1 else {
|
|
DLToast.show(text: "说话时间太短")
|
|
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
|
|
return
|
|
}
|
|
|
|
// Send voice message via OpenIM
|
|
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
|
|
OIMManager.manager.sendMessage(msg,
|
|
recvID: "",
|
|
groupID: viewModel.groupId,
|
|
offlinePushInfo: nil,
|
|
onSuccess: { [weak self] _ in
|
|
self?.viewModel.onReceiveMessage(msg)
|
|
},
|
|
onProgress: nil as OIMNumberCallback?,
|
|
onFailure: { code, errMsg in
|
|
print("Voice send failed: \(code) \(errMsg ?? "")")
|
|
})
|
|
}
|
|
|
|
// MARK: - API
|
|
private func requestGroupInfoByKey() {
|
|
GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in
|
|
guard let model = response.model else { return }
|
|
self.viewModel.memberList = response.list
|
|
self.rootView.groupNameLabel.text = model.name
|
|
self.rootView.groupAvatarView.image = model.groupIcon
|
|
self.viewModel.loadMessages()
|
|
}.disposed(by: disposeBag)
|
|
}
|
|
}
|
|
|
|
// MARK: - MessageListenerProxy
|
|
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
|
|
private let handler: (OIMMessageInfo) -> Void
|
|
|
|
init(handler: @escaping (OIMMessageInfo) -> Void) {
|
|
self.handler = handler
|
|
}
|
|
|
|
func onRecvNewMessage(_ msg: OIMMessageInfo) {
|
|
handler(msg)
|
|
}
|
|
}
|