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

582 lines
22 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.

//
// GroupChatVC.swift
// QuickLocation
//
// Created by on 2026/6/4.
//
import UIKit
import RxSwift
import RxCocoa
import RxDataSources
import OpenIMSDK
import AVFoundation
import AudioToolbox
import HXPHPicker
import IQKeyboardManagerSwift
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()
setupKeyboard()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
IQKeyboardManager.shared.isEnabled = false
IQKeyboardManager.shared.resignOnTouchOutside = 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()
IQKeyboardManager.shared.isEnabled = true
IQKeyboardManager.shared.resignOnTouchOutside = true
}
// MARK: - Keyboard
private func setupKeyboard() {
//
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
let userInfo = noti.userInfo,
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let height = frame.height
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
// /
self.rootView.dismissAllPanels(excludeTextField: true)
UIView.animate(withDuration: duration) {
self.rootView.bottomBar.layoutChain.bottom(height + kSafeBottomMargin + 20)
}
self.scrollToBottom()
})
.disposed(by: disposeBag)
}
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
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)
}
}
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.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
break
case .notDetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
guard granted else { return }
DispatchQueue.main.async {
self.rootView.dismissAllPanels()
self.showSpeakPanel()
}
}
return
default:
Permission.openAppSetting(title: "请开启麦克风权限",
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
return
}
self.rootView.dismissAllPanels()
self.showSpeakPanel()
})
.disposed(by: disposeBag)
//
rootView.voiceRecordView.keyboardBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.rootView.textField.becomeFirstResponder()
})
.disposed(by: disposeBag)
//
rootView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.showEmojiPanel()
})
.disposed(by: disposeBag)
rootView.voiceRecordView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.showEmojiPanel()
})
.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)
//
Observable.merge(
rootView.addBtn.rx.tap.asObservable(),
rootView.voiceRecordView.addBtn.rx.tap.asObservable()
)
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.showAlbum()
})
.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)
//
rootView.settingBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
AppRouter.push(Route.groupSetting, userInfo: ["groupId": self.viewModel.groupId])
})
.disposed(by: disposeBag)
}
// MARK: -
private func showSpeakPanel() {
let show = rootView.voiceRecordView.isHidden
rootView.voiceRecordView.isHidden = !show
let offset: CGFloat = show ? 252 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin + 20)
self.rootView.voiceRecordView.layoutChain.bottom(offset - 252 + kSafeBottomMargin)
}
scrollToBottom()
}
// MARK: -
private func showEmojiPanel() {
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)
// }
scrollToBottom()
if show {
self.rootView.textField.resignFirstResponder()
EmojiPanelCell.preloadAnimations()
}
}
// MARK: -
private func showAlbum() {
//
let config = PhotoTools.getWXPickerConfig()
// 0
config.selectOptions = [.photo]
config.selectMode = .multiple
config.maximumSelectedCount = 9
// config.maximumSelectedPhotoFileSize = 5242880
config.allowSyncICloudWhenSelectPhoto = false
config.previewView.bottomView.editButtonHidden = true
config.photoList.allowAddCamera = true
config.photoList.camera.allowsEditing = false
let pickerController = PhotoPickerController(picker: config)
pickerController.pickerDelegate = self
pickerController.modalPresentationStyle = .fullScreen
self.present(pickerController, animated: true, completion: nil)
}
// 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: - 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)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .imageReceived(msg):
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .notification(text):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text)
return cell
}
}
}()
}
// 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)
}
}
// MARK: - PhotoPickerControllerDelegate
extension GroupChatVC: PhotoPickerControllerDelegate {
///
/// - Parameters:
/// - pickerController: PhotoPickerController
/// - result:
/// result.photoAssets
/// result.isOriginal
func pickerController(_ pickerController: PhotoPickerController,
didFinishSelection result: PickerResult) {
result.getImage { (image, photoAsset, index) in
} completionHandler: { [weak self] (images) in
guard let self = self else { return }
for img in images {
self.sendImageMessage(img)
}
}
}
private func sendImageMessage(_ image: UIImage) {
guard let data = image.jpegData(compressionQuality: 0.8) else { return }
let dir = NSTemporaryDirectory()
let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg"
let fileURL = URL(fileURLWithPath: dir + filename)
try? data.write(to: fileURL)
let displaySize = Self.imageDisplaySize(w: image.size.width, h: image.size.height)
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
// 使 SDK clientMsgID ID便
let localId = msg.clientMsgID ?? UUID().uuidString
// loading
let localMsg = ChatMessage(
id: localId,
isSelf: true,
avatar: viewModel.getUserAvatar(id: AppContextManager.shared.userId),
senderName: AppContextManager.shared.name,
content: "",
voiceUrl: "",
imageUrl: fileURL.path,
imageWidth: displaySize.width,
imageHeight: displaySize.height,
timestamp: Date().timeIntervalSince1970,
showTime: false,
isUploading: true
)
viewModel.appendLocalMessage(.imageSend(localMsg))
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: viewModel.groupId,
offlinePushInfo: nil,
onSuccess: { [weak self] returnedMsg in
// URLloading
// returnedMsg SDK OIMMessageInfo URL
let networkUrl = returnedMsg?.pictureElem?.sourcePicture?.url
?? returnedMsg?.pictureElem?.bigPicture?.url
?? returnedMsg?.pictureElem?.sourcePath
?? ""
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
// URL
if !networkUrl.isEmpty {
chatMsg.imageUrl = networkUrl
}
chatMsg.isUploading = false
}
try? FileManager.default.removeItem(at: fileURL)
},
onProgress: nil as OIMNumberCallback?,
onFailure: { [weak self] code, errMsg in
print("Image send failed: \(code) \(errMsg ?? "")")
// loading
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
chatMsg.isUploading = false
}
})
}
private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
var dw = maxW, dh = dw * (h / w)
if dh > maxH { dh = maxH; dw = dh * (w / h) }
if dw < minW { dw = minW; dh = dw * (h / w) }
return CGSize(width: dw, height: dh)
}
///
/// - Parameter pickerController: PhotoPickerController
func pickerController(didCancel pickerController: PhotoPickerController) {
}
}