// // 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> = { RxCollectionViewSectionedReloadDataSource> { _, 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 = { RxTableViewSectionedReloadDataSource { _, 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 // 服务端返回后,更新本地消息为服务端图片URL,去掉loading // 注意: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) { } }