ios_app_v2/yobble/Views/Chat/PrivateChatView.swift
2025-10-23 18:30:41 +03:00

620 lines
23 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.

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct PrivateChatView: View {
let chat: PrivateChatListItem
let currentUserId: String?
private let bottomAnchorId = "PrivateChatBottomAnchor"
@StateObject private var viewModel: PrivateChatViewModel
@State private var hasPositionedToBottom: Bool = false
@State private var scrollToBottomTrigger: UUID = .init()
@State private var isBottomAnchorVisible: Bool = true
@State private var draftText: String = ""
@State private var inputTab: ComposerTab = .chat
@State private var isVideoPreferred: Bool = false
@State private var legacyComposerHeight: CGFloat = 40
@FocusState private var isComposerFocused: Bool
@EnvironmentObject private var messageCenter: IncomingMessageCenter
init(chat: PrivateChatListItem, currentUserId: String?) {
self.chat = chat
self.currentUserId = currentUserId
_viewModel = StateObject(wrappedValue: PrivateChatViewModel(chatId: chat.chatId, currentUserId: currentUserId))
}
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .bottomTrailing) {
content
.onChange(of: viewModel.messages.count) { _ in
guard !viewModel.isLoadingMore else { return }
scrollToBottom(proxy: proxy)
}
.onChange(of: scrollToBottomTrigger) { _ in
scrollToBottom(proxy: proxy)
}
if !isBottomAnchorVisible {
scrollToBottomButton(proxy: proxy)
.padding(.trailing, 12)
.padding(.bottom, 4)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.loadInitialHistory()
}
.onAppear {
messageCenter.activeChatId = chat.chatId
}
.onChange(of: viewModel.isInitialLoading) { isLoading in
if isLoading {
hasPositionedToBottom = false
}
}
.safeAreaInset(edge: .bottom) {
composer
}
.onDisappear {
if messageCenter.activeChatId == chat.chatId {
messageCenter.activeChatId = nil
}
}
}
@ViewBuilder
private var content: some View {
if viewModel.isInitialLoading && viewModel.messages.isEmpty {
ProgressView(NSLocalizedString("Загрузка сообщений…", comment: ""))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.errorMessage, viewModel.messages.isEmpty {
errorView(message: error)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
if viewModel.isLoadingMore {
loadingMoreView
} else if viewModel.messages.isEmpty {
emptyState
}
ForEach(viewModel.messages) { message in
messageRow(for: message)
.id(message.id)
.onAppear {
guard hasPositionedToBottom else { return }
viewModel.loadMoreIfNeeded(for: message)
}
}
if let message = viewModel.errorMessage,
!message.isEmpty,
!viewModel.messages.isEmpty {
errorBanner(message: message)
}
Color.clear
.frame(height: 1)
.id(bottomAnchorId)
.onAppear { isBottomAnchorVisible = true }
.onDisappear { isBottomAnchorVisible = false }
}
.padding(.vertical, 12)
}
.simultaneousGesture(
DragGesture().onChanged { value in
guard value.translation.height > 0 else { return }
isComposerFocused = false
}
)
.refreshable {
viewModel.refresh()
}
}
}
private var emptyState: some View {
Text(NSLocalizedString("В чате пока нет сообщений.", comment: ""))
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 32)
}
private var loadingMoreView: some View {
HStack {
ProgressView()
Text(NSLocalizedString("Загружаем ранние сообщения…", comment: ""))
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
private func errorView(message: String) -> some View {
VStack(spacing: 12) {
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Повторить", comment: ""))
.font(.body)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func messageRow(for message: MessageItem) -> some View {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack(alignment: .bottom, spacing: 12) {
if isCurrentUser { Spacer(minLength: 32) }
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) {
// if !isCurrentUser {
// Text(senderName(for: message))
// .font(.caption)
// .foregroundColor(.secondary)
// }
HStack(alignment: .bottom) {
Text(contentText(for: message))
.font(.body)
.foregroundColor(isCurrentUser ? .white : .primary)
.multilineTextAlignment(.leading)
Text(timestamp(for: message))
.font(.caption2)
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
.fixedSize(horizontal: false, vertical: true)
if !isCurrentUser { Spacer(minLength: 32) }
}
.padding(.horizontal, 16)
}
private var messageBubbleMaxWidth: CGFloat {
min(UIScreen.main.bounds.width * 0.72, 360)
}
private func senderName(for message: MessageItem) -> String {
if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return full
}
if let custom = message.senderData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return custom
}
if let login = message.senderData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "@\(login)"
}
return message.senderId
}
private func timestamp(for message: MessageItem) -> String {
guard let date = message.createdAt else {
return ""
}
return Self.timeFormatter.string(from: date)
}
private func contentText(for message: MessageItem) -> String {
guard let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty else {
return NSLocalizedString("(без текста)", comment: "")
}
return content
}
private func errorBanner(message: String) -> some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.footnote)
.foregroundColor(.primary)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
.padding(.top, 8)
}
private var composer: some View {
VStack(spacing: 10) {
HStack(alignment: .bottom, spacing: 4) {
Button(action: { }) { // переключатель на стикеры
Image(systemName: "paperclip")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
ZStack(alignment: .bottomTrailing) {
Group {
if #available(iOS 160.0, *) {
TextField(inputTab.placeholder, text: $draftText, axis: .vertical)
.lineLimit(1...4)
.focused($isComposerFocused)
.submitLabel(.send)
.disabled(currentUserId == nil)
.onSubmit { sendCurrentMessage() }
} else {
LegacyMultilineTextView(
text: $draftText,
placeholder: inputTab.placeholder,
isFocused: Binding(
get: { isComposerFocused },
set: { isComposerFocused = $0 }
),
isEnabled: currentUserId != nil,
minHeight: 10,
maxLines: 4,
calculatedHeight: $legacyComposerHeight,
onSubmit: sendCurrentMessage
)
.frame(height: legacyComposerHeight)
}
}
.padding(.top, 10)
.padding(.leading, 12)
.padding(.trailing, 44)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading)
Button(action: { }) { // переключатель на стикеры
Image(systemName: "face.smiling")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.trailing, 12)
.padding(.bottom, 10)
}
.frame(minHeight: 40, alignment: .bottom)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.alignmentGuide(.bottom) { dimension in
dimension[VerticalAlignment.bottom] - 2
}
if !isSendAvailable {
Button(action: { isVideoPreferred.toggle() }) {
Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
} else {
sendButton
}
}
}
.padding(.horizontal, 6)
.padding(.top, 10)
.padding(.bottom, 8)
.background(.ultraThinMaterial)
}
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(proxy: proxy)
} label: {
Image(systemName: "arrow.down")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
// .background(Color.accentColor)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
// .overlay(
// Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)
// )
}
.buttonStyle(.plain)
.shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
}
private var isSendDisabled: Bool {
draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || currentUserId == nil
}
private var isSendAvailable: Bool {
!draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentUserId != nil
}
private var sendButton: some View {
Button(action: sendCurrentMessage) {
Image(systemName: "leaf.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.white.opacity(isSendDisabled ? 0.6 : 1))
.frame(width: 36, height: 36)
.background(isSendDisabled ? Color.accentColor.opacity(0.4) : Color.accentColor)
.clipShape(Circle())
}
.disabled(isSendDisabled)
.buttonStyle(.plain)
}
// private func composerToolbarButton(systemName: String, action: @escaping () -> Void) -> some View {
// Button(action: action) {
// Image(systemName: systemName)
// .font(.system(size: 16, weight: .medium))
// }
private func composerModeButton(_ tab: ComposerTab) -> some View {
Button(action: { inputTab = tab }) {
Text(tab.title)
.font(.caption)
.fontWeight(inputTab == tab ? .semibold : .regular)
.padding(.vertical, 8)
.padding(.horizontal, 14)
.background(
Group {
if inputTab == tab {
Color.accentColor.opacity(0.15)
} else {
Color(.secondarySystemBackground)
}
}
)
.foregroundColor(inputTab == tab ? .accentColor : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sendCurrentMessage() {
let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
draftText = ""
scrollToBottomTrigger = .init()
viewModel.sendMessage(text: text) { success in
if success {
hasPositionedToBottom = true
}
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
hasPositionedToBottom = true
let targetId = viewModel.messages.last?.id ?? bottomAnchorId
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(targetId, anchor: .bottom)
}
}
}
private struct ComposerIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: 36, height: 36)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
.overlay(
Circle().stroke(Color.secondary.opacity(0.15))
)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
}
}
private enum ComposerTab: String {
case chat
case stickers
var title: String {
switch self {
case .chat: return NSLocalizedString("Чат", comment: "")
case .stickers: return NSLocalizedString("Стикеры", comment: "")
}
}
var iconName: String {
switch self {
case .chat: return "text.bubble"
case .stickers: return "face.smiling"
}
}
var placeholder: String {
switch self {
case .chat: return NSLocalizedString("Сообщение", comment: "")
case .stickers: return NSLocalizedString("Поиск стикеров", comment: "")
}
}
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private var title: String {
if let full = chat.chatData?.fullName, !full.isEmpty {
return full
}
if let custom = chat.chatData?.customName, !custom.isEmpty {
return custom
}
if let login = chat.chatData?.login, !login.isEmpty {
return "@\(login)"
}
return NSLocalizedString("Чат", comment: "")
}
}
#if canImport(UIKit)
private struct LegacyMultilineTextView: UIViewRepresentable {
@Binding var text: String
var placeholder: String
@Binding var isFocused: Bool
var isEnabled: Bool
var minHeight: CGFloat
var maxLines: Int
@Binding var calculatedHeight: CGFloat
var onSubmit: (() -> Void)?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = false
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.text = text
textView.returnKeyType = .send
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let placeholderLabel = context.coordinator.placeholderLabel
placeholderLabel.text = placeholder
placeholderLabel.font = textView.font
placeholderLabel.textColor = UIColor.secondaryLabel
placeholderLabel.numberOfLines = 1
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
textView.addSubview(placeholderLabel)
NSLayoutConstraint.activate([
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor)
])
context.coordinator.updatePlaceholderVisibility(for: textView)
DispatchQueue.main.async {
Self.recalculateHeight(for: textView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines)
}
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
uiView.isEditable = isEnabled
uiView.isSelectable = isEnabled
uiView.textColor = isEnabled ? UIColor.label : UIColor.secondaryLabel
let placeholderLabel = context.coordinator.placeholderLabel
if placeholderLabel.text != placeholder {
placeholderLabel.text = placeholder
}
placeholderLabel.font = uiView.font
context.coordinator.updatePlaceholderVisibility(for: uiView)
if isFocused && !uiView.isFirstResponder {
uiView.becomeFirstResponder()
} else if !isFocused && uiView.isFirstResponder {
uiView.resignFirstResponder()
}
Self.recalculateHeight(for: uiView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines)
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
private var calculatedHeightBinding: Binding<CGFloat> {
Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 })
}
private static func recalculateHeight(for textView: UITextView, result: Binding<CGFloat>, minHeight: CGFloat, maxLines: Int) {
let width = textView.bounds.width
guard width > 0 else {
DispatchQueue.main.async {
recalculateHeight(for: textView, result: result, minHeight: minHeight, maxLines: maxLines)
}
return
}
let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude)
let targetSize = textView.sizeThatFits(fittingSize)
let lineHeight = textView.font?.lineHeight ?? UIFont.preferredFont(forTextStyle: .body).lineHeight
let maxHeight = minHeight + lineHeight * CGFloat(max(maxLines - 1, 0))
let clampedHeight = min(max(targetSize.height, minHeight), maxHeight)
if abs(result.wrappedValue - clampedHeight) > 0.5 {
result.wrappedValue = clampedHeight
}
textView.isScrollEnabled = targetSize.height > maxHeight + 0.5
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: LegacyMultilineTextView
let placeholderLabel = UILabel()
init(parent: LegacyMultilineTextView) {
self.parent = parent
}
func textViewDidBeginEditing(_ textView: UITextView) {
if !parent.isFocused {
parent.isFocused = true
}
}
func textViewDidEndEditing(_ textView: UITextView) {
if parent.isFocused {
parent.isFocused = false
}
}
func textViewDidChange(_ textView: UITextView) {
if parent.text != textView.text {
parent.text = textView.text
}
updatePlaceholderVisibility(for: textView)
LegacyMultilineTextView.recalculateHeight(for: textView, result: parent.calculatedHeightBinding, minHeight: parent.minHeight, maxLines: parent.maxLines)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
if let onSubmit = parent.onSubmit {
DispatchQueue.main.async {
onSubmit()
}
return false
}
}
return true
}
func updatePlaceholderVisibility(for textView: UITextView) {
placeholderLabel.isHidden = !textView.text.isEmpty
}
}
}
#endif
// MARK: - Preview
// Previews intentionally omitted - MessageItem has custom decoding-only initializer.