// // GroupChatView.swift // QuickLocation // // Created by 八条 on 2026/6/4. // import UIKit import RxSwift import RxCocoa import Lottie import AVFoundation enum VoiceRecordState { case began case canceling case ended(URL?) // nil = cancelled } // MARK: - Message Model struct ChatMessage { let id: String let isSelf: Bool let senderId: String let avatar: UIImage let senderName: String let content: String let voiceUrl: String var imageUrl: String let imageWidth: CGFloat let imageHeight: CGFloat let timestamp: TimeInterval var showTime: Bool = false var isUploading: Bool = false var quotePreview: QuotePreview? = nil var atUserIDs: [String] = [] var atNicknames: [String] = [] var isAtAll: Bool = false func with(avatar: UIImage? = nil, showTime: Bool? = nil, isUploading: Bool? = nil, imageUrl: String? = nil, quotePreview: QuotePreview? = nil) -> ChatMessage { ChatMessage( id: id, isSelf: isSelf, senderId: senderId, avatar: avatar ?? self.avatar, senderName: senderName, content: content, voiceUrl: voiceUrl, imageUrl: imageUrl ?? self.imageUrl, imageWidth: imageWidth, imageHeight: imageHeight, timestamp: timestamp, showTime: showTime ?? self.showTime, isUploading: isUploading ?? self.isUploading, quotePreview: quotePreview ?? self.quotePreview, atUserIDs: atUserIDs, atNicknames: atNicknames, isAtAll: isAtAll ) } /// 正文 attributed(含 @ 高亮) func attributedContent(isOutgoing: Bool) -> NSAttributedString { let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333") let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF") let result = NSMutableAttributedString( string: content, attributes: [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: baseColor ] ) var tokens = atNicknames.map { "@\($0)" } if isAtAll { tokens.append("@所有人") } for token in Set(tokens) where !token.isEmpty && token != "@" { var searchRange = NSRange(location: 0, length: result.length) while searchRange.location < result.length { let found = (result.string as NSString).range(of: token, options: [], range: searchRange) if found.location == NSNotFound { break } result.addAttribute(.foregroundColor, value: atColor, range: found) result.addAttribute(.font, value: UIFont.systemFont(ofSize: 14, weight: .semibold), range: found) let next = found.location + found.length searchRange = NSRange(location: next, length: result.length - next) } } return result } } class GroupChatView: UIView { var disposeBag = DisposeBag() /// For scrolling control from VC var scrollToBottom: (() -> Void)? /// bottomBar 底部约束 var bottomBarBottomConstraint: NSLayoutConstraint? /// 语音录制回调 var onVoiceRecordState: ((VoiceRecordState) -> Void)? private var emojiPlaybackGeneration = 0 private var quoteBarHeightConstraint: NSLayoutConstraint? func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) { quoteBar.isHidden = !visible quoteBarHeightConstraint?.constant = visible ? 36 : 0 if let summary { quoteBarLabel.text = summary } UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() } } private func setupRx() { } /// 收起所有浮层面板 func dismissAllPanels(excludeTextField: Bool = false) { let needsReset = !emojiPanelView.isHidden || !voiceRecordView.isHidden || textField.isFirstResponder || !mentionPickerView.isHidden guard needsReset else { return } stopVisibleEmojiAnimations() emojiPanelView.isHidden = true voiceRecordView.isHidden = true mentionPickerView.isHidden = true if !excludeTextField { textField.resignFirstResponder() } UIView.animate(withDuration: 0.25) { self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20) self.voiceRecordView.layoutChain.bottom(-252) } } func playVisibleEmojiAnimations() { emojiPlaybackGeneration += 1 let generation = emojiPlaybackGeneration let indexPaths = emojiCollectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item } for (offset, indexPath) in indexPaths.enumerated() { DispatchQueue.main.asyncAfter(deadline: .now() + Double(offset) * 0.02) { [weak self] in guard let self = self, self.emojiPlaybackGeneration == generation, !self.emojiCollectionView.isDragging, !self.emojiCollectionView.isDecelerating, let cell = self.emojiCollectionView.cellForItem(at: indexPath) as? EmojiPanelCell else { return } cell.playAnimation() } } } func stopVisibleEmojiAnimations() { emojiPlaybackGeneration += 1 emojiCollectionView.visibleCells.forEach { cell in (cell as? EmojiPanelCell)?.stopAnimation() } } func preloadAdjacentEmojiPages() { let items = UIView.emojiFileNames guard emojiCollectionView.bounds.width > 0, !items.isEmpty else { return } let currentPage = Int(round(emojiCollectionView.contentOffset.x / emojiCollectionView.bounds.width)) for page in [currentPage - 1, currentPage + 1] where page >= 0 { let start = page * Self.emojiPerPage guard start < items.count else { continue } let end = min(start + Self.emojiPerPage, items.count) items[start.. Void)? var onRevoke: (() -> Void)? var onDismiss: (() -> Void)? private let panel = UIView() private let arrow = UIView() private let stack = UIStackView() private let dimView = UIControl() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear dimView.backgroundColor = .clear dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) addSubview(dimView) dimView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ dimView.topAnchor.constraint(equalTo: topAnchor), dimView.leadingAnchor.constraint(equalTo: leadingAnchor), dimView.trailingAnchor.constraint(equalTo: trailingAnchor), dimView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) panel.backgroundColor = UIColor(hexStr: "#333333") panel.layer.cornerRadius = 10 panel.clipsToBounds = true addSubview(panel) stack.axis = .horizontal stack.alignment = .fill stack.distribution = .fill stack.spacing = 0 panel.addSubview(stack) stack.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: panel.topAnchor), stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor), stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor), stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor), panel.heightAnchor.constraint(equalToConstant: 44) ]) arrow.backgroundColor = UIColor(hexStr: "#333333") addSubview(arrow) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func show(in host: UIView, anchor: UIView, showRevoke: Bool) { removeFromSuperview() host.addSubview(self) frame = host.bounds autoresizingMask = [.flexibleWidth, .flexibleHeight] stack.arrangedSubviews.forEach { stack.removeArrangedSubview($0) $0.removeFromSuperview() } let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote)) quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true stack.addArrangedSubview(quoteBtn) if showRevoke { let sep = UIView() sep.backgroundColor = UIColor.white.withAlphaComponent(0.2) sep.translatesAutoresizingMaskIntoConstraints = false sep.widthAnchor.constraint(equalToConstant: 1).isActive = true stack.addArrangedSubview(sep) let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke)) revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true stack.addArrangedSubview(revokeBtn) } let panelW: CGFloat = showRevoke ? 145 : 72 let panelH: CGFloat = 44 let arrowSize: CGFloat = 8 let gap: CGFloat = 6 layoutIfNeeded() let anchorRect = anchor.convert(anchor.bounds, to: host) var panelX = anchorRect.midX - panelW / 2 panelX = max(12, min(panelX, host.bounds.width - panelW - 12)) var panelY = anchorRect.minY - gap - panelH - arrowSize var arrowAbove = true if panelY < host.safeAreaInsets.top + 8 { panelY = anchorRect.maxY + gap + arrowSize arrowAbove = false } panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH) let arrowX = min(max(anchorRect.midX - arrowSize / 2, panelX + 10), panelX + panelW - arrowSize - 10) if arrowAbove { arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowSize, height: arrowSize) } else { arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowSize + 1, width: arrowSize, height: arrowSize) } arrow.transform = CGAffineTransform(rotationAngle: .pi / 4) alpha = 0 UIView.animate(withDuration: 0.15) { self.alpha = 1 } } private func makeButton(title: String, action: Selector) -> UIButton { let btn = UIButton(type: .system) btn.setTitle(title, for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) btn.addTarget(self, action: action, for: .touchUpInside) btn.translatesAutoresizingMaskIntoConstraints = false return btn } @objc private func handleQuote() { onQuote?() dismiss() } @objc private func handleRevoke() { onRevoke?() dismiss() } @objc private func handleDismiss() { dismiss() } func dismiss() { UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }, completion: { _ in self.onDismiss?() self.removeFromSuperview() }) } } private func chatFormatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } // MARK: - 发送的消息cell class TextSendMsgCell: UITableViewCell { var onQuoteTap: (() -> Void)? private(set) var messageId: String = "" var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { messageId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar contentLabel.attributedText = msg.attributedContent(isOutgoing: true) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) quoteHeightConstraint?.constant = hasQuote ? 28 : 0 quoteTopConstraint?.constant = hasQuote ? 4 : 0 } func flashHighlight() { let old = contentView.backgroundColor contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { self.contentView.backgroundColor = old } } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white return iv }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#16B3FF") return v }() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.numberOfLines = 0 return label }() private let quoteBlock = ChatQuoteBlockView() private var quoteHeightConstraint: NSLayoutConstraint? private var quoteTopConstraint: NSLayoutConstraint? override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12).width(30).height(30) bubbleView.layoutChain .topToBottomOfView(avatarView, offset: -15) .rightToView(avatarView, offset: -13) .left(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.translatesAutoresizingMaskIntoConstraints = false quoteBlock.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10) ]) let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop quoteHeightConstraint = qH NSLayoutConstraint.activate([ qTop, quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor), quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60), quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } @objc private func handleQuoteTap() { onQuoteTap?() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } // MARK: - 收到的消息cell class TextReceivedMsgCell: UITableViewCell { var onQuoteTap: (() -> Void)? private(set) var messageId: String = "" private(set) var senderId: String = "" private(set) var senderName: String = "" var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { messageId = msg.id senderId = msg.senderId senderName = msg.senderName timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName contentLabel.attributedText = msg.attributedContent(isOutgoing: false) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) quoteHeightConstraint?.constant = hasQuote ? 28 : 0 quoteTopConstraint?.constant = hasQuote ? 4 : 0 } func flashHighlight() { let old = contentView.backgroundColor contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12) UIView.animate(withDuration: 0.8, delay: 0.3, options: []) { self.contentView.backgroundColor = old } } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .regular) label.textColor = UIColor(hexStr: "#666666") return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#CCEAFF") return v }() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.numberOfLines = 0 return label }() private let quoteBlock = ChatQuoteBlockView() private var quoteHeightConstraint: NSLayoutConstraint? private var quoteTopConstraint: NSLayoutConstraint? override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) contentView.addSubview(nameLabel) timeLabel.layoutChain.top().centerX() bubbleView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .left(30) .right(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.translatesAutoresizingMaskIntoConstraints = false quoteBlock.translatesAutoresizingMaskIntoConstraints = false avatarView.translatesAutoresizingMaskIntoConstraints = false nameLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10), // 头像 + 昵称贴在气泡下方 avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15), avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), avatarView.widthAnchor.constraint(equalToConstant: 30), avatarView.heightAnchor.constraint(equalToConstant: 30), nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor) ]) // 引用在 senderName 下方 let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop quoteHeightConstraint = qH NSLayoutConstraint.activate([ qTop, quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60), quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside) } @objc private func handleQuoteTap() { onQuoteTap?() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } // MARK: - 通知消息cell final class NotificationMsgCell: UITableViewCell { func configure(_ text: NSAttributedString, showTime: Bool, timestamp: TimeInterval) { timeLabel.isHidden = !showTime timeLabel.text = showTime ? formatTime(timestamp) : nil contentLabel.attributedText = text } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center label.numberOfLines = 0 return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(contentLabel) timeLabel.layoutChain .top(10) .centerX() contentLabel.layoutChain .topToBottomOfView(timeLabel, offset: 8) .edgesHorzontal(40) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - 发送的表情消息 final class EmojiSendMsgCell: UITableViewCell { var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white return iv }() private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit v.loopMode = .loop return v }() private var animationName: String? private var shouldPlayAnimation = false override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12) .width(30).height(30) lottieView.layoutChain .topToBottomOfView(avatarView, offset: -15) .right(60) .width(60).height(60) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil lottieView.animation = nil return } let name = Self.emojiFileNames[index] animationName = name shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil lottieView.currentProgress = 0 EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in guard let self = self, self.animationName == name else { return } self.lottieView.animation = animation self.lottieView.currentProgress = 0 if self.shouldPlayAnimation { self.lottieView.play() } } } func playAnimation() { shouldPlayAnimation = true lottieView.play() } func stopAnimation() { shouldPlayAnimation = false lottieView.stop() } override func prepareForReuse() { super.prepareForReuse() animationName = nil shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } } // MARK: - 收到的表情消息 final class EmojiReceivedMsgCell: UITableViewCell { var menuAnchorView: UIView { lottieView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .regular) label.textColor = UIColor(hexStr: "#666666") return label }() private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit v.loopMode = .loop return v }() private var animationName: String? private var shouldPlayAnimation = false override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) contentView.addSubview(nameLabel) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() lottieView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .left(60) .width(60).height(60) avatarView.layoutChain .topToBottomOfView(lottieView, offset: -15) .left(12) .width(30).height(30) .bottom(10) nameLabel.layoutChain .leftToRightOfView(avatarView, offset: 5) .bottomToView(avatarView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil lottieView.animation = nil return } let name = Self.emojiFileNames[index] animationName = name shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil lottieView.currentProgress = 0 EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in guard let self = self, self.animationName == name else { return } self.lottieView.animation = animation self.lottieView.currentProgress = 0 if self.shouldPlayAnimation { self.lottieView.play() } } } func playAnimation() { shouldPlayAnimation = true lottieView.play() } func stopAnimation() { shouldPlayAnimation = false lottieView.stop() } override func prepareForReuse() { super.prepareForReuse() animationName = nil shouldPlayAnimation = false lottieView.stop() lottieView.animation = nil } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } } // MARK: - UICollectionViewDelegate (page control) extension GroupChatView: UICollectionViewDelegate { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { guard scrollView == emojiCollectionView else { return } stopVisibleEmojiAnimations() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard scrollView == emojiCollectionView, !decelerate else { return } playVisibleEmojiAnimations() preloadAdjacentEmojiPages() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { guard scrollView == emojiCollectionView else { return } let page = Int(scrollView.contentOffset.x / scrollView.bounds.width) emojiPageControl.currentPage = page playVisibleEmojiAnimations() preloadAdjacentEmojiPages() } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard collectionView == emojiCollectionView else { return } guard !collectionView.isDragging, !collectionView.isDecelerating else { return } (cell as? EmojiPanelCell)?.playAnimation() } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard collectionView == emojiCollectionView else { return } (cell as? EmojiPanelCell)?.stopAnimation() } } // MARK: - 发送的语音消息 final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { var menuAnchorView: UIView { bubbleView } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white return iv }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#16B3FF") return v }() private let playAnimation: LottieAnimationView = { let v = LottieAnimationView() if let path = Bundle.main.path(forResource: "message_voice_play", ofType: "json") { v.animation = LottieAnimation.filepath(path) } // v.loopMode = .loop v.contentMode = .scaleAspectFit return v }() private let durationLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hexStr: "#1A1A1A") return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12).width(30).height(30) bubbleView.layoutChain .topToBottomOfView(avatarView, offset: -15) .rightToView(avatarView, offset: -13) .width(105).height(39).bottom(10) playAnimation.layoutChain .right(36) .centerY() .width(20).height(20) durationLabel.layoutChain .rightToLeftOfView(playAnimation, offset: -4) .centerY() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl } private var voiceUrl: String? @objc private func togglePlay() { guard let url = voiceUrl, !url.isEmpty else { return } if VoicePlayerManager.shared.state == .playing, VoicePlayerManager.shared.isCurrent(url: url) { VoicePlayerManager.shared.pause() playAnimation.stop() } else { VoicePlayerManager.shared.play(urlString: url, playbackView: self) { [weak self] state in if state == .paused { self?.playAnimation.stop() } else { self?.playAnimation.play() } } onFinished: { [weak self] in self?.playAnimation.stop() } } } func play() { playAnimation.play() } func stop() { playAnimation.stop() } override func prepareForReuse() { super.prepareForReuse() playAnimation.stop() } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() bubbleView.setCornerRadius(corners: [.topLeft , .bottomRight, .bottomLeft], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } // MARK: - 收到的语音消息 final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { var menuAnchorView: UIView { bubbleView } func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl } private var voiceUrl: String? @objc private func togglePlay() { guard let url = voiceUrl, !url.isEmpty else { return } if VoicePlayerManager.shared.state == .playing, VoicePlayerManager.shared.isCurrent(url: url) { VoicePlayerManager.shared.pause() playAnimation.stop() } else { VoicePlayerManager.shared.play(urlString: url, playbackView: self) { [weak self] state in if state == .paused { self?.playAnimation.stop() } else { self?.playAnimation.play() } } onFinished: { [weak self] in self?.playAnimation.stop() } } } func play() { playAnimation.play() } func stop() { playAnimation.stop() } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .regular) label.textColor = UIColor(hexStr: "#666666") return label }() private let bubbleView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#CCEAFF") return v }() private let playAnimation: LottieAnimationView = { let v = LottieAnimationView() if let path = Bundle.main.path(forResource: "message_voice_play", ofType: "json") { v.animation = LottieAnimation.filepath(path) } v.loopMode = .loop v.contentMode = .scaleAspectFit v.transform = CGAffineTransform(rotationAngle: .pi) return v }() private let durationLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hexStr: "#1A1A1A") return label }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(bubbleView) bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) contentView.addSubview(nameLabel) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() bubbleView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .width(105) .height(39) avatarView.layoutChain .topToBottomOfView(bubbleView, offset: -15) .left(12) .width(30).height(30) .bottom(10) bubbleView.layoutChain.leftToView(avatarView, offset: 13) nameLabel.layoutChain .leftToRightOfView(avatarView, offset: 5) .bottomToView(avatarView) playAnimation.layoutChain .left(36) .centerY() .width(20).height(20) durationLabel.layoutChain .leftToRightOfView(playAnimation, offset: 4) .centerY() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() playAnimation.stop() } override func layoutSubviews() { super.layoutSubviews() bubbleView.setNeedsLayout() bubbleView.layoutIfNeeded() bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight], withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } // MARK: - 发送的图片消息 final class ImageSendMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar if !msg.imageUrl.isEmpty { // 优先加载本地文件路径,否则走网络加载 if let localImage = UIImage(contentsOfFile: msg.imageUrl) { photoView.image = localImage } else { photoView.dl.setImage(with: msg.imageUrl) } } photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) loadingView.isHidden = !msg.isUploading if msg.isUploading { loadingView.startAnimating() } } @objc private func onTap() { onImageTap?() } private let loadingView: UIActivityIndicatorView = { let v = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.large) v.hidesWhenStopped = true v.color = UIColor(hexStr: "#16B3FF") return v }() private static func displaySize(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) } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() private let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white return iv }() lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#F0F0F0") iv.isUserInteractionEnabled = true return iv }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(photoView) contentView.addSubview(avatarView) let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) photoView.addSubview(loadingView) loadingView.layoutChain.centerX().centerY() timeLabel.layoutChain.top().centerX() avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12).width(30).height(30) photoView.layoutChain .topToBottomOfView(avatarView, offset: -15) .rightToView(avatarView, offset: -13) .bottom(10) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - 收到的图片消息 final class ImageReceivedMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar nameLabel.text = msg.senderName if !msg.imageUrl.isEmpty { photoView.dl.setImage(with: msg.imageUrl) } photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) } @objc private func onTap() { onImageTap?() } private static func displaySize(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) } private func formatTime(_ t: TimeInterval) -> String { let date = Date(timeIntervalSince1970: t) let now = Date() let calendar = Calendar.current let f = DateFormatter() if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" } else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" } else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" } else { f.dateFormat = "yyyy-M-d HH:mm" } return f.string(from: date) } private let timeLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center return label }() let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 15 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.borderWidth = 2 iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 10, weight: .regular) label.textColor = UIColor(hexStr: "#666666") return label }() lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#F0F0F0") iv.isUserInteractionEnabled = true return iv }() override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(nameLabel) contentView.addSubview(photoView) contentView.addSubview(avatarView) let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() photoView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .width(160).height(160) avatarView.layoutChain .topToBottomOfView(photoView, offset: -15) .left(12) .width(30).height(30) .bottom(10) photoView.layoutChain.leftToView(avatarView, offset: 13) nameLabel.layoutChain .leftToRightOfView(avatarView, offset: 5) .bottomToView(avatarView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }