import SwiftUI struct PrivateChatView: View { let chat: PrivateChatListItem let currentUserId: String? @StateObject private var viewModel: PrivateChatViewModel @State private var hasPositionedToBottom: Bool = false @State private var draftText: String = "" @State private var inputTab: ComposerTab = .chat @State private var isVideoPreferred: Bool = false @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 content .onChange(of: viewModel.messages.count) { _ in guard !viewModel.isLoadingMore, let lastId = viewModel.messages.last?.id else { return } DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(lastId, anchor: .bottom) } hasPositionedToBottom = true } } } .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) } } .padding(.vertical, 12) } .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) // } Text(contentText(for: message)) .font(.body) .foregroundColor(isCurrentUser ? .white : .primary) .multilineTextAlignment(isCurrentUser ? .trailing : .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)) .frame(width: 40, height: 40) .foregroundColor(.secondary) } ZStack(alignment: .bottomTrailing) { TextField(inputTab.placeholder, text: $draftText, axis: .vertical) .lineLimit(1...4) .focused($isComposerFocused) .submitLabel(.send) .disabled(viewModel.isSending || currentUserId == nil) .onSubmit { sendCurrentMessage() } .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)) if !isSendAvailable { Button(action: { isVideoPreferred.toggle() }) { Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill") .font(.system(size: 18, weight: .semibold)) .frame(width: 40, height: 40) .foregroundColor(.secondary) } .buttonStyle(.plain) } else { sendButton } } } .padding(.horizontal, 6) .padding(.top, 10) .padding(.bottom, 8) .background(.ultraThinMaterial) } private var isSendDisabled: Bool { draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil } private var isSendAvailable: Bool { !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentUserId != nil } private var sendButton: some View { Button(action: sendCurrentMessage) { Image(systemName: "paperplane.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 } viewModel.sendMessage(text: text) { success in if success { draftText = "" hasPositionedToBottom = true } } } 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: "") } } // MARK: - Preview // Previews intentionally omitted - MessageItem has custom decoding-only initializer.