369 lines
13 KiB
Swift
369 lines
13 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: 12) {
|
||
|
||
Button(action: { }) { // переключатель на стикеры
|
||
Image(systemName: "paperclip")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.frame(width: 40, height: 40)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack(alignment: .center, spacing: 8) {
|
||
TextField(inputTab.placeholder, text: $draftText, axis: .vertical)
|
||
.lineLimit(1...4)
|
||
.focused($isComposerFocused)
|
||
.submitLabel(.send)
|
||
.disabled(viewModel.isSending || currentUserId == nil)
|
||
.onSubmit { sendCurrentMessage() }
|
||
Button(action: { }) { // переключатель на стикеры
|
||
Image(systemName: "face.smiling")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.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))
|
||
.frame(width: 40, height: 40)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.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, 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.
|