ios_app_v2/yobble/Views/Chat/PrivateChatView.swift
2025-10-22 03:51:34 +03:00

274 lines
9.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
@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: 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.