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: 40, 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 { Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 }) } private static func recalculateHeight(for textView: UITextView, result: Binding, 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) let shouldScroll = targetSize.height > maxHeight + 0.5 if abs(result.wrappedValue - clampedHeight) > 0.5 { let newHeight = clampedHeight DispatchQueue.main.async { if abs(result.wrappedValue - newHeight) > 0.5 { result.wrappedValue = newHeight } } } textView.isScrollEnabled = shouldScroll } 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.