375 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			375 lines
		
	
	
		
			14 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 = ""
 | 
						||
    @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: 3) {
 | 
						||
                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, 10)
 | 
						||
        .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, 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.
 |