ios_app_v2/yobble/Views/Tab/ChatsTab.swift
2025-10-07 05:35:10 +03:00

888 lines
30 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.

//
// ChatsTab.swift
// VolnahubApp
//
// Created by cheykrym on 09/06/2025.
//
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct ChatsTab: View {
var currentUserId: String?
@Binding var searchRevealProgress: CGFloat
@Binding var searchText: String
private let searchService = SearchService()
@StateObject private var viewModel = PrivateChatsViewModel()
@State private var selectedChatId: String?
@State private var searchDragStartProgress: CGFloat = 0
@State private var isSearchGestureActive: Bool = false
@State private var globalSearchResults: [UserSearchResult] = []
@State private var isGlobalSearchLoading: Bool = false
@State private var globalSearchError: String?
@State private var globalSearchTask: Task<Void, Never>? = nil
private let searchRevealDistance: CGFloat = 90
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
self.currentUserId = currentUserId
self._searchRevealProgress = searchRevealProgress
self._searchText = searchText
}
var body: some View {
content
.background(Color(UIColor.systemBackground))
.onAppear {
viewModel.loadInitialChats()
handleSearchQueryChange(searchText)
}
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
viewModel.refresh()
}
.onChange(of: searchText) { newValue in
handleSearchQueryChange(newValue)
}
.onDisappear {
globalSearchTask?.cancel()
globalSearchTask = nil
}
}
@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(.vertical, 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 isSearching {
Section(header: localSearchHeader) {
if localSearchResults.isEmpty {
emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
}
}
}
Section(header: globalSearchHeader) {
globalSearchContent
}
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
}
if viewModel.isLoadingMore {
loadingMoreRow
}
}
}
.listStyle(.plain)
.modifier(ScrollDismissesKeyboardModifier())
.simultaneousGesture(searchBarGesture)
.simultaneousGesture(tapToDismissKeyboardGesture)
// .safeAreaInset(edge: .top) {
// VStack(spacing: 0) {
// searchBar
// .padding(.horizontal, 16)
// .padding(.top, 8)
// .padding(.bottom, 8)
// Divider()
// }
// .background(Color(UIColor.systemBackground))
// }
}
private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
let verticalTranslation = value.translation.height
let horizontalTranslation = value.translation.width
if !isSearchGestureActive {
guard abs(verticalTranslation) > abs(horizontalTranslation) else { return }
if searchRevealProgress <= 0.001 && verticalTranslation < 0 { return }
isSearchGestureActive = true
searchDragStartProgress = searchRevealProgress
}
guard isSearchGestureActive else { return }
let delta = verticalTranslation / searchRevealDistance
let newProgress = searchDragStartProgress + delta
searchRevealProgress = max(0, min(1, newProgress))
}
.onEnded { _ in
guard isSearchGestureActive else { return }
isSearchGestureActive = false
let target: CGFloat = searchRevealProgress > 0.5 ? 1 : 0
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
searchRevealProgress = target
}
}
}
private var tapToDismissKeyboardGesture: some Gesture {
TapGesture().onEnded {
dismissKeyboard()
}
}
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 localSearchResults: [PrivateChatListItem] {
Array(filteredChats.prefix(5))
}
private var localSearchHeader: some View {
Text(NSLocalizedString("Локальные чаты", comment: "Local search section"))
}
private var globalSearchHeader: some View {
Text(NSLocalizedString("Глобальный поиск", comment: "Global search section"))
}
@ViewBuilder
private var globalSearchContent: some View {
if isGlobalSearchLoading {
globalSearchLoadingRow
} else if let error = globalSearchError {
globalSearchErrorRow(message: error)
} else if globalSearchResults.isEmpty {
globalSearchEmptyRow
} else {
ForEach(globalSearchResults) { user in
globalSearchRow(for: user)
}
}
}
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)
}
@ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
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)
}
}
private var globalSearchLoadingRow: some View {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text(NSLocalizedString("Ищем пользователей…", comment: "Global search loading"))
.font(.footnote)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private func globalSearchErrorRow(message: String) -> some View {
Text(message)
.font(.footnote)
.foregroundColor(.orange)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private var globalSearchEmptyRow: some View {
Text(NSLocalizedString("Ничего не найдено", comment: "Global search empty state"))
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private func globalSearchRow(for user: UserSearchResult) -> some View {
HStack(spacing: 12) {
Circle()
.fill(user.isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Text(user.avatarInitial)
.font(.headline)
.foregroundColor(user.isOfficial ? .white : Color.accentColor)
)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(user.displayName)
.fontWeight(user.isOfficial ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
if user.isOfficial {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
}
if let secondary = user.secondaryLabelForSearch {
Text(secondary)
.font(.footnote)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
private extension ChatsTab {
func dismissKeyboard() {
#if canImport(UIKit)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
func handleSearchQueryChange(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
resetGlobalSearch()
return
}
globalSearchTask?.cancel()
globalSearchTask = nil
guard trimmed.count >= 2 else {
globalSearchResults = []
globalSearchError = nil
isGlobalSearchLoading = false
return
}
isGlobalSearchLoading = true
globalSearchError = nil
globalSearchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
do {
let data = try await searchService.search(query: trimmed)
guard !Task.isCancelled else { return }
await MainActor.run {
globalSearchResults = data.users
isGlobalSearchLoading = false
globalSearchError = nil
globalSearchTask = nil
}
} catch is CancellationError {
// Ignore cancellation
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
globalSearchResults = []
isGlobalSearchLoading = false
globalSearchError = friendlyErrorMessage(for: error)
globalSearchTask = nil
}
}
}
}
func resetGlobalSearch() {
globalSearchTask?.cancel()
globalSearchTask = nil
globalSearchResults = []
globalSearchError = nil
isGlobalSearchLoading = false
}
func friendlyErrorMessage(for error: Error) -> String {
if let searchError = error as? SearchServiceError {
return searchError.errorDescription ?? NSLocalizedString("Не удалось выполнить поиск.", comment: "Search error fallback")
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Search error unauthorized")
case .invalidURL, .noResponse:
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Search error connection")
case .network(let underlying):
return underlying.localizedDescription
case .server(let statusCode, _):
return String(format: NSLocalizedString("Сервер вернул ошибку (%d).", comment: "Search error server status"), statusCode)
}
}
if (error as NSError).code == NSURLErrorCancelled { // доп. подстраховка
return NSLocalizedString("Поиск отменён.", comment: "Search cancelled")
}
return NSLocalizedString("Произошла неизвестная ошибка.", comment: "Search unknown error")
}
}
private struct ScrollDismissesKeyboardModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content.scrollDismissesKeyboard(.interactively)
} else {
content
}
}
}
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 {
struct Wrapper: View {
@State private var progress: CGFloat = 1
@State private var searchText: String = ""
var body: some View {
ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
.environmentObject(ThemeManager())
}
}
static var previews: some View {
Wrapper()
}
}
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")
}