624 lines
21 KiB
Swift
624 lines
21 KiB
Swift
//
|
||
// ChatsTab.swift
|
||
// VolnahubApp
|
||
//
|
||
// Created by cheykrym on 09/06/2025.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
struct ChatsTab: View {
|
||
var currentUserId: String? = nil
|
||
@StateObject private var viewModel = PrivateChatsViewModel()
|
||
@State private var selectedChatId: String?
|
||
@State private var searchText: String = ""
|
||
|
||
var body: some View {
|
||
content
|
||
.background(Color(UIColor.systemBackground))
|
||
.onAppear {
|
||
viewModel.loadInitialChats()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
|
||
viewModel.refresh()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||
loadingState
|
||
} else if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||
errorState(message: message)
|
||
} else if viewModel.chats.isEmpty {
|
||
emptyState
|
||
} else {
|
||
chatList
|
||
}
|
||
}
|
||
|
||
private var chatList: some View {
|
||
List {
|
||
VStack(spacing: 0) {
|
||
searchBar
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.background(Color(UIColor.systemBackground))
|
||
|
||
if let message = viewModel.errorMessage {
|
||
Section {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.orange)
|
||
Text(message)
|
||
.font(.subheadline)
|
||
.foregroundColor(.orange)
|
||
Spacer(minLength: 0)
|
||
Button(action: { viewModel.refresh() }) {
|
||
Text(NSLocalizedString("Обновить", comment: ""))
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||
}
|
||
|
||
if filteredChats.isEmpty && isSearching {
|
||
Section {
|
||
emptySearchResultView
|
||
}
|
||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
} else {
|
||
ForEach(filteredChats) { chat in
|
||
Button {
|
||
selectedChatId = chat.chatId
|
||
} label: {
|
||
ChatRowView(chat: chat, currentUserId: currentUserId)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
Button(action: {}) {
|
||
Label(NSLocalizedString("Закрепить (скоро)", comment: ""), systemImage: "pin")
|
||
}
|
||
|
||
Button(action: {}) {
|
||
Label(NSLocalizedString("Без звука (скоро)", comment: ""), systemImage: "speaker.slash")
|
||
}
|
||
|
||
Button(role: .destructive, action: {}) {
|
||
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
||
}
|
||
}
|
||
.background(
|
||
NavigationLink(
|
||
destination: ChatPlaceholderView(chat: chat),
|
||
tag: chat.chatId,
|
||
selection: $selectedChatId
|
||
) {
|
||
EmptyView()
|
||
}
|
||
.hidden()
|
||
)
|
||
.onAppear {
|
||
guard !isSearching else { return }
|
||
viewModel.loadMoreIfNeeded(currentItem: chat)
|
||
}
|
||
}
|
||
}
|
||
|
||
if viewModel.isLoadingMore && !isSearching {
|
||
loadingMoreRow
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
// .safeAreaInset(edge: .top) {
|
||
// VStack(spacing: 0) {
|
||
// searchBar
|
||
// .padding(.horizontal, 16)
|
||
// .padding(.top, 8)
|
||
// .padding(.bottom, 8)
|
||
// Divider()
|
||
// }
|
||
// .background(Color(UIColor.systemBackground))
|
||
// }
|
||
}
|
||
|
||
private var searchBar: some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundColor(.secondary)
|
||
TextField(NSLocalizedString("Поиск", comment: ""), text: $searchText)
|
||
.textFieldStyle(.plain)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
if !searchText.isEmpty {
|
||
Button(action: { searchText = "" }) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(Color(UIColor.secondarySystemBackground))
|
||
)
|
||
}
|
||
|
||
private var isSearching: Bool {
|
||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
private var filteredChats: [PrivateChatListItem] {
|
||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmedQuery.isEmpty else { return viewModel.chats }
|
||
let lowercasedQuery = trimmedQuery.lowercased()
|
||
return viewModel.chats.filter { chat in
|
||
let searchableValues = [
|
||
chat.chatData?.customName,
|
||
chat.chatData?.fullName,
|
||
chat.chatData?.login,
|
||
chat.lastMessage?.content
|
||
]
|
||
return searchableValues
|
||
.compactMap { $0?.lowercased() }
|
||
.contains(where: { $0.contains(lowercasedQuery) })
|
||
}
|
||
}
|
||
|
||
private var emptySearchResultView: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "text.magnifyingglass")
|
||
.font(.system(size: 40))
|
||
.foregroundColor(.secondary)
|
||
Text(NSLocalizedString("Ничего не найдено", comment: ""))
|
||
.font(.headline)
|
||
Text(NSLocalizedString("Попробуйте изменить запрос поиска.", comment: ""))
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
private var loadingState: some View {
|
||
VStack(spacing: 12) {
|
||
ProgressView()
|
||
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private func errorState(message: String) -> some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "exclamationmark.bubble")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.orange)
|
||
Text(message)
|
||
.font(.body)
|
||
.multilineTextAlignment(.center)
|
||
.foregroundColor(.primary)
|
||
Button(action: { viewModel.loadInitialChats(force: true) }) {
|
||
Text(NSLocalizedString("Повторить", comment: ""))
|
||
.font(.headline)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "bubble.left")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.secondary)
|
||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||
.font(.body)
|
||
.foregroundColor(.secondary)
|
||
Button(action: { viewModel.refresh() }) {
|
||
Text(NSLocalizedString("Обновить", comment: ""))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private var loadingMoreRow: some View {
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.padding(.vertical, 12)
|
||
Spacer()
|
||
}
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
}
|
||
|
||
private struct ChatRowView: View {
|
||
let chat: PrivateChatListItem
|
||
let currentUserId: String?
|
||
|
||
private var title: String {
|
||
switch chat.chatType {
|
||
case .self:
|
||
return NSLocalizedString("Избранные сообщения", comment: "")
|
||
case .privateChat, .unknown:
|
||
if let custom = chat.chatData?.customName, !custom.isEmpty {
|
||
return custom
|
||
}
|
||
if let full = chat.chatData?.fullName, !full.isEmpty {
|
||
return full
|
||
}
|
||
if let login = chat.chatData?.login, !login.isEmpty {
|
||
return "@\(login)"
|
||
}
|
||
return NSLocalizedString("Неизвестный пользователь", comment: "")
|
||
}
|
||
}
|
||
|
||
private var officialFullName: String? {
|
||
guard let name = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else {
|
||
return nil
|
||
}
|
||
return NSLocalizedString(name, comment: "")
|
||
}
|
||
|
||
private var loginDisplay: String? {
|
||
guard let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty else {
|
||
return nil
|
||
}
|
||
return "@\(login)"
|
||
}
|
||
|
||
private var isOfficial: Bool {
|
||
officialFullName != nil
|
||
}
|
||
|
||
private var avatarBackgroundColor: Color {
|
||
isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
|
||
}
|
||
|
||
private var avatarTextColor: Color {
|
||
isOfficial ? Color.white : Color.accentColor
|
||
}
|
||
|
||
private var messagePreview: String {
|
||
guard let message = chat.lastMessage else {
|
||
return NSLocalizedString("Нет сообщений", comment: "")
|
||
}
|
||
|
||
let body = messageBody(for: message)
|
||
guard let prefix = authorPrefix(for: message) else {
|
||
return body
|
||
}
|
||
return body.isEmpty ? prefix : "\(body)"
|
||
// return body.isEmpty ? prefix : "\(prefix): \(body)"
|
||
}
|
||
|
||
private func messageBody(for message: MessageItem) -> String {
|
||
if let content = trimmed(message.content), !content.isEmpty {
|
||
return content
|
||
}
|
||
|
||
if message.mediaLink != nil {
|
||
return NSLocalizedString("Вложение", comment: "")
|
||
}
|
||
|
||
return NSLocalizedString("Сообщение", comment: "")
|
||
}
|
||
|
||
private func authorPrefix(for message: MessageItem) -> String? {
|
||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||
if isCurrentUser {
|
||
return NSLocalizedString("Вы", comment: "")
|
||
}
|
||
|
||
let profile = message.senderData ?? chat.chatData
|
||
return displayName(for: profile)
|
||
}
|
||
|
||
private func displayName(for profile: ChatProfile?) -> String? {
|
||
guard let profile else { return nil }
|
||
|
||
let login = trimmed(profile.login)
|
||
let customName = trimmed(profile.customName)
|
||
let fullName = trimmed(profile.fullName)
|
||
let isOfficialProfile = fullName != nil
|
||
|
||
if isOfficialProfile, let login {
|
||
if let customName {
|
||
return "@\(login) (\(customName))"
|
||
}
|
||
return "@\(login)"
|
||
}
|
||
|
||
if let customName {
|
||
return customName
|
||
}
|
||
|
||
if let login {
|
||
return "@\(login)"
|
||
}
|
||
|
||
return fullName
|
||
}
|
||
|
||
private func trimmed(_ string: String?) -> String? {
|
||
guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
|
||
private var timestamp: String? {
|
||
let date = chat.lastMessage?.createdAt ?? chat.createdAt
|
||
guard let date else { return nil }
|
||
return ChatRowView.formattedTimestamp(for: date)
|
||
}
|
||
|
||
private var initial: String {
|
||
let sourceName: String
|
||
|
||
if let full = officialFullName {
|
||
sourceName = full
|
||
} else if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
sourceName = custom
|
||
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||
sourceName = login
|
||
} else {
|
||
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
|
||
}
|
||
|
||
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||
return String(character).uppercased()
|
||
}
|
||
|
||
return "?"
|
||
}
|
||
|
||
private var subtitleColor: Color {
|
||
chat.unreadCount > 0 ? .primary : .secondary
|
||
}
|
||
|
||
private var shouldShowReadStatus: Bool {
|
||
guard let message = chat.lastMessage else { return false }
|
||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||
return isCurrentUser
|
||
}
|
||
|
||
private var readStatusIconName: String {
|
||
guard let message = chat.lastMessage else { return "" }
|
||
return message.isViewed == true ? "checkmark.circle.fill" : "checkmark.circle"
|
||
}
|
||
|
||
private var readStatusColor: Color {
|
||
guard let message = chat.lastMessage else { return .secondary }
|
||
return message.isViewed == true ? Color.accentColor : Color.secondary
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Circle()
|
||
.fill(avatarBackgroundColor)
|
||
.frame(width: 44, height: 44)
|
||
.overlay(
|
||
Text(initial)
|
||
.font(.headline)
|
||
.foregroundColor(avatarTextColor)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let officialName = officialFullName {
|
||
HStack(spacing: 6) {
|
||
Text(officialName)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.foregroundColor(Color.accentColor)
|
||
.font(.caption)
|
||
}
|
||
|
||
// if let login = loginDisplay {
|
||
// Text(login)
|
||
// .font(.footnote)
|
||
// .foregroundColor(.secondary)
|
||
// .lineLimit(1)
|
||
// .truncationMode(.tail)
|
||
// }
|
||
} else {
|
||
Text(title)
|
||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
|
||
Text(messagePreview)
|
||
.font(.subheadline)
|
||
.foregroundColor(subtitleColor)
|
||
.lineLimit(2)
|
||
.truncationMode(.tail)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Spacer()
|
||
|
||
VStack(alignment: .trailing, spacing: 6) {
|
||
if let timestamp {
|
||
HStack(spacing: 4) {
|
||
if shouldShowReadStatus {
|
||
Image(systemName: readStatusIconName)
|
||
.foregroundColor(readStatusColor)
|
||
.font(.caption2)
|
||
}
|
||
|
||
Text(timestamp)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
if chat.unreadCount > 0 {
|
||
Text("\(chat.unreadCount)")
|
||
.font(.caption2.bold())
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
Capsule().fill(Color.accentColor)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
private static func formattedTimestamp(for date: Date) -> String {
|
||
let calendar = Calendar.current
|
||
let locale = Locale.current
|
||
|
||
let now = Date()
|
||
let startOfNow = calendar.startOfDay(for: now)
|
||
let startOfDate = calendar.startOfDay(for: date)
|
||
let diff = calendar.dateComponents([.day], from: startOfDate, to: startOfNow).day ?? 0
|
||
|
||
if diff <= 0 {
|
||
return timeString(for: date, locale: locale)
|
||
}
|
||
|
||
if diff == 1 {
|
||
return locale.identifier.lowercased().hasPrefix("ru") ? "Вчера" : "Yesterday"
|
||
}
|
||
|
||
if diff < 7 {
|
||
return weekdayFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
if diff < 365 {
|
||
return monthDayFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
return fullDateFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
private static func timeString(for date: Date, locale: Locale) -> String {
|
||
let timeFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: locale) ?? ""
|
||
let is12Hour = timeFormat.contains("a") || timeFormat.contains("h") || timeFormat.contains("K")
|
||
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
if is12Hour {
|
||
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "h:mm a", options: 0, locale: locale) ?? "h:mm a"
|
||
} else {
|
||
formatter.dateFormat = "HH:mm"
|
||
}
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private static func weekdayFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.setLocalizedDateFormatFromTemplate("EEE")
|
||
return formatter
|
||
}
|
||
|
||
private static func monthDayFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.setLocalizedDateFormatFromTemplate("MMM d")
|
||
return formatter
|
||
}
|
||
|
||
private static func fullDateFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.dateFormat = "dd.MM.yy"
|
||
return formatter
|
||
}
|
||
}
|
||
|
||
struct ChatsTab_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
ChatsTab()
|
||
.environmentObject(ThemeManager())
|
||
}
|
||
}
|
||
|
||
private struct ChatPlaceholderView: View {
|
||
let chat: PrivateChatListItem
|
||
|
||
private var companionId: String {
|
||
if let profileId = chat.chatData?.userId {
|
||
return profileId
|
||
}
|
||
if let senderId = chat.lastMessage?.senderId {
|
||
return senderId
|
||
}
|
||
return NSLocalizedString("Неизвестный", comment: "")
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
Text(NSLocalizedString("Экран чата в разработке", comment: ""))
|
||
.font(.headline)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("Chat ID:")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
Text(chat.chatId)
|
||
.font(.body.monospaced())
|
||
}
|
||
|
||
if companionId != NSLocalizedString("Неизвестный", comment: "") {
|
||
HStack {
|
||
Text("Companion ID:")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
Text(companionId)
|
||
.font(.body.monospaced())
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.navigationTitle(title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
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: "")
|
||
}
|
||
}
|
||
|
||
extension Notification.Name {
|
||
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
||
}
|