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) { Divider() HStack(spacing: 12) { composerToolbarButton(systemName: "paperclip", label: NSLocalizedString("Вложения", comment: "")) { // TODO: hook to attachments action } composerModeButton(.chat) composerModeButton(.stickers) Spacer(minLength: 0) } HStack(alignment: .bottom, spacing: 12) { HStack(alignment: .center, spacing: 8) { Image(systemName: inputTab.iconName) .foregroundColor(.secondary) TextField(inputTab.placeholder, text: $draftText, axis: .vertical) .lineLimit(1...4) .focused($isComposerFocused) .submitLabel(.send) .disabled(viewModel.isSending || currentUserId == nil) .onSubmit { sendCurrentMessage() } } .padding(.vertical, 10) .padding(.horizontal, 12) .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)) .foregroundColor(.accentColor) .frame(width: 40, height: 40) .background(Color.accentColor.opacity(0.12)) .clipShape(Circle()) } .buttonStyle(.plain) } else { sendButton } } } .padding(.horizontal, 16) .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: viewModel.isSending ? "hourglass" : "paperplane.fill") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.white) .frame(width: 40, height: 40) .background(Color.accentColor) .clipShape(Circle()) } .disabled(isSendDisabled) .buttonStyle(.plain) } private func composerToolbarButton(systemName: String, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 4) { Image(systemName: systemName) .font(.system(size: 16, weight: .medium)) Text(label) .font(.caption2) } .foregroundColor(.primary) .frame(width: 64, height: 44) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) } 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.