259 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			259 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
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 = ""
 | 
						||
    @FocusState private var isComposerFocused: Bool
 | 
						||
 | 
						||
    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()
 | 
						||
        }
 | 
						||
        .onChange(of: viewModel.isInitialLoading) { isLoading in
 | 
						||
            if isLoading {
 | 
						||
                hasPositionedToBottom = false
 | 
						||
            }
 | 
						||
        }
 | 
						||
        .safeAreaInset(edge: .bottom) {
 | 
						||
            composer
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    @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 {
 | 
						||
            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)
 | 
						||
                    .frame(maxWidth: .infinity, alignment: 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))
 | 
						||
 | 
						||
            if !isCurrentUser { Spacer(minLength: 32) }
 | 
						||
        }
 | 
						||
        .padding(.horizontal, 16)
 | 
						||
    }
 | 
						||
 | 
						||
    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: 0) {
 | 
						||
            Divider()
 | 
						||
 | 
						||
            HStack(alignment: .center, spacing: 12) {
 | 
						||
                TextField(NSLocalizedString("Сообщение", comment: ""), text: $draftText, axis: .vertical)
 | 
						||
                    .lineLimit(1...4)
 | 
						||
                    .focused($isComposerFocused)
 | 
						||
                    .submitLabel(.send)
 | 
						||
                    .disabled(viewModel.isSending || currentUserId == nil)
 | 
						||
                    .onSubmit { sendCurrentMessage() }
 | 
						||
 | 
						||
                Button(action: sendCurrentMessage) {
 | 
						||
                    Image(systemName: viewModel.isSending ? "hourglass" : "paperplane.fill")
 | 
						||
                        .font(.system(size: 18, weight: .semibold))
 | 
						||
                }
 | 
						||
                .disabled(isSendDisabled)
 | 
						||
                .buttonStyle(.plain)
 | 
						||
            }
 | 
						||
            .padding(.vertical, 10)
 | 
						||
            .padding(.horizontal, 16)
 | 
						||
            .background(.clear)
 | 
						||
        }
 | 
						||
        .background(.ultraThinMaterial)
 | 
						||
    }
 | 
						||
 | 
						||
    private var isSendDisabled: Bool {
 | 
						||
        draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil
 | 
						||
    }
 | 
						||
 | 
						||
    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 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.
 |