Compare commits
	
		
			3 Commits
		
	
	
		
			15ef27b42f
			...
			7ce0fb3cd3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce0fb3cd3 | |||
| a5b2c2702c | |||
| 26b8dfc71a | 
							
								
								
									
										111
									
								
								yobble/Network/SearchModels.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								yobble/Network/SearchModels.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,111 @@
 | 
				
			|||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct SearchDataPayload: Decodable {
 | 
				
			||||||
 | 
					    let users: [UserSearchResult]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct UserSearchResult: Decodable, Identifiable {
 | 
				
			||||||
 | 
					    let userId: UUID
 | 
				
			||||||
 | 
					    let login: String
 | 
				
			||||||
 | 
					    let fullName: String?
 | 
				
			||||||
 | 
					    let customName: String?
 | 
				
			||||||
 | 
					    let createdAt: Date?
 | 
				
			||||||
 | 
					    let profile: SearchProfile?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var id: UUID { userId }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct SearchProfile: Decodable {
 | 
				
			||||||
 | 
					    let userId: UUID
 | 
				
			||||||
 | 
					    let login: String?
 | 
				
			||||||
 | 
					    let fullName: String?
 | 
				
			||||||
 | 
					    let customName: String?
 | 
				
			||||||
 | 
					    let bio: String?
 | 
				
			||||||
 | 
					    let lastSeen: Int?
 | 
				
			||||||
 | 
					    let createdAt: Date?
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extension UserSearchResult {
 | 
				
			||||||
 | 
					    var displayName: String {
 | 
				
			||||||
 | 
					        if let official = officialFullName {
 | 
				
			||||||
 | 
					            return official
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let custom = preferredCustomName {
 | 
				
			||||||
 | 
					            return custom
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let profileFull = trimmed(profile?.fullName) {
 | 
				
			||||||
 | 
					            return profileFull
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let profileCustom = trimmed(profile?.customName) {
 | 
				
			||||||
 | 
					            return profileCustom
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return loginHandle
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var secondaryText: String? {
 | 
				
			||||||
 | 
					        // Отдельное поле для совместимости с существующими вызовами
 | 
				
			||||||
 | 
					//        if let official = officialFullName, official != displayName {
 | 
				
			||||||
 | 
					//            return official
 | 
				
			||||||
 | 
					//        }
 | 
				
			||||||
 | 
					        if let profileLogin = trimmed(profile?.login), profileLogin != login {
 | 
				
			||||||
 | 
					            return "@\(profileLogin)"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var officialFullName: String? {
 | 
				
			||||||
 | 
					        trimmed(fullName) ?? trimmed(profile?.fullName)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var preferredCustomName: String? {
 | 
				
			||||||
 | 
					        trimmed(customName) ?? trimmed(profile?.customName)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var loginHandle: String {
 | 
				
			||||||
 | 
					        "@\(login)"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var secondaryLabelForSearch: String? {
 | 
				
			||||||
 | 
					        if let official = officialFullName {
 | 
				
			||||||
 | 
					//            if let custom = preferredCustomName, custom != official {
 | 
				
			||||||
 | 
					//                return custom
 | 
				
			||||||
 | 
					//            }
 | 
				
			||||||
 | 
					            if official != loginHandle {
 | 
				
			||||||
 | 
					                return loginHandle
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let secondary = secondaryText, secondary != displayName {
 | 
				
			||||||
 | 
					            return secondary
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if displayName != loginHandle {
 | 
				
			||||||
 | 
					            return loginHandle
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var avatarInitial: String {
 | 
				
			||||||
 | 
					        let source = officialFullName
 | 
				
			||||||
 | 
					            ?? preferredCustomName
 | 
				
			||||||
 | 
					            ?? trimmed(profile?.login)
 | 
				
			||||||
 | 
					            ?? login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let character = source.first(where: { !$0.isWhitespace && $0 != "@" }) {
 | 
				
			||||||
 | 
					            return String(character).uppercased()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return "?"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var isOfficial: Bool {
 | 
				
			||||||
 | 
					        officialFullName != nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func trimmed(_ value: String?) -> String? {
 | 
				
			||||||
 | 
					        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
 | 
				
			||||||
 | 
					            return nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										171
									
								
								yobble/Network/SearchService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								yobble/Network/SearchService.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,171 @@
 | 
				
			|||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum SearchServiceError: LocalizedError {
 | 
				
			||||||
 | 
					    case unexpectedStatus(String)
 | 
				
			||||||
 | 
					    case decoding(debugDescription: String)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var errorDescription: String? {
 | 
				
			||||||
 | 
					        switch self {
 | 
				
			||||||
 | 
					        case .unexpectedStatus(let message):
 | 
				
			||||||
 | 
					            return message
 | 
				
			||||||
 | 
					        case .decoding(let debugDescription):
 | 
				
			||||||
 | 
					            return AppConfig.DEBUG
 | 
				
			||||||
 | 
					                ? debugDescription
 | 
				
			||||||
 | 
					                : NSLocalizedString("Не удалось выполнить поиск.", comment: "Search service decoding error")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class SearchService {
 | 
				
			||||||
 | 
					    private let client: NetworkClient
 | 
				
			||||||
 | 
					    private let decoder: JSONDecoder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(client: NetworkClient = .shared) {
 | 
				
			||||||
 | 
					        self.client = client
 | 
				
			||||||
 | 
					        self.decoder = JSONDecoder()
 | 
				
			||||||
 | 
					        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
				
			||||||
 | 
					        self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func search(query: String, completion: @escaping (Result<SearchDataPayload, Error>) -> Void) {
 | 
				
			||||||
 | 
					        let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
 | 
				
			||||||
 | 
					        guard !trimmedQuery.isEmpty else {
 | 
				
			||||||
 | 
					            completion(.success(SearchDataPayload(users: [])))
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        client.request(
 | 
				
			||||||
 | 
					            path: "/v1/feed/search",
 | 
				
			||||||
 | 
					            method: .get,
 | 
				
			||||||
 | 
					            query: ["query": trimmedQuery],
 | 
				
			||||||
 | 
					            requiresAuth: true
 | 
				
			||||||
 | 
					        ) { [decoder] result in
 | 
				
			||||||
 | 
					            switch result {
 | 
				
			||||||
 | 
					            case .success(let response):
 | 
				
			||||||
 | 
					                do {
 | 
				
			||||||
 | 
					                    let apiResponse = try decoder.decode(APIResponse<SearchDataPayload>.self, from: response.data)
 | 
				
			||||||
 | 
					                    guard apiResponse.status == "fine" else {
 | 
				
			||||||
 | 
					                        let message = apiResponse.detail ?? NSLocalizedString("Не удалось найти результаты.", comment: "Search unexpected status")
 | 
				
			||||||
 | 
					                        completion(.failure(SearchServiceError.unexpectedStatus(message)))
 | 
				
			||||||
 | 
					                        return
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    completion(.success(apiResponse.data))
 | 
				
			||||||
 | 
					                } catch {
 | 
				
			||||||
 | 
					                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
				
			||||||
 | 
					                    if AppConfig.DEBUG {
 | 
				
			||||||
 | 
					                        print("[SearchService] decode search response failed: \(debugMessage)")
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    completion(.failure(SearchServiceError.decoding(debugDescription: debugMessage)))
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            case .failure(let error):
 | 
				
			||||||
 | 
					                if case let NetworkError.server(_, data) = error,
 | 
				
			||||||
 | 
					                   let data,
 | 
				
			||||||
 | 
					                   let message = Self.errorMessage(from: data) {
 | 
				
			||||||
 | 
					                    completion(.failure(SearchServiceError.unexpectedStatus(message)))
 | 
				
			||||||
 | 
					                    return
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                completion(.failure(error))
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func search(query: String) async throws -> SearchDataPayload {
 | 
				
			||||||
 | 
					        try await withCheckedThrowingContinuation { continuation in
 | 
				
			||||||
 | 
					            search(query: query) { result in
 | 
				
			||||||
 | 
					                continuation.resume(with: result)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func decodeDate(from decoder: Decoder) throws -> Date {
 | 
				
			||||||
 | 
					        let container = try decoder.singleValueContainer()
 | 
				
			||||||
 | 
					        let string = try container.decode(String.self)
 | 
				
			||||||
 | 
					        if let date = iso8601WithFractionalSeconds.date(from: string) {
 | 
				
			||||||
 | 
					            return date
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let date = iso8601Simple.date(from: string) {
 | 
				
			||||||
 | 
					            return date
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        throw DecodingError.dataCorruptedError(
 | 
				
			||||||
 | 
					            in: container,
 | 
				
			||||||
 | 
					            debugDescription: "Невозможно декодировать дату: \(string)"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func describeDecodingError(error: Error, data: Data) -> String {
 | 
				
			||||||
 | 
					        var parts: [String] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let decodingError = error as? DecodingError {
 | 
				
			||||||
 | 
					            parts.append(decodingDescription(from: decodingError))
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            parts.append(error.localizedDescription)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let payload = truncatedPayload(from: data) {
 | 
				
			||||||
 | 
					            parts.append("payload=\(payload)")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return parts.joined(separator: "\n")
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func decodingDescription(from error: DecodingError) -> String {
 | 
				
			||||||
 | 
					        switch error {
 | 
				
			||||||
 | 
					        case .typeMismatch(let type, let context):
 | 
				
			||||||
 | 
					            return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
 | 
				
			||||||
 | 
					        case .valueNotFound(let type, let context):
 | 
				
			||||||
 | 
					            return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
 | 
				
			||||||
 | 
					        case .keyNotFound(let key, let context):
 | 
				
			||||||
 | 
					            return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
 | 
				
			||||||
 | 
					        case .dataCorrupted(let context):
 | 
				
			||||||
 | 
					            return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
 | 
				
			||||||
 | 
					        @unknown default:
 | 
				
			||||||
 | 
					            return error.localizedDescription
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func codingPath(from context: DecodingError.Context) -> String {
 | 
				
			||||||
 | 
					        let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
 | 
				
			||||||
 | 
					        return path.isEmpty ? "root" : path.joined(separator: ".")
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
 | 
				
			||||||
 | 
					        guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
 | 
				
			||||||
 | 
					              !string.isEmpty else {
 | 
				
			||||||
 | 
					            return nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if string.count <= limit {
 | 
				
			||||||
 | 
					            return string
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let index = string.index(string.startIndex, offsetBy: limit)
 | 
				
			||||||
 | 
					        return String(string[string.startIndex..<index]) + "…"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
 | 
				
			||||||
 | 
					        let formatter = ISO8601DateFormatter()
 | 
				
			||||||
 | 
					        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 | 
				
			||||||
 | 
					        return formatter
 | 
				
			||||||
 | 
					    }()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static let iso8601Simple: ISO8601DateFormatter = {
 | 
				
			||||||
 | 
					        let formatter = ISO8601DateFormatter()
 | 
				
			||||||
 | 
					        formatter.formatOptions = [.withInternetDateTime]
 | 
				
			||||||
 | 
					        return formatter
 | 
				
			||||||
 | 
					    }()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func errorMessage(from data: Data) -> String? {
 | 
				
			||||||
 | 
					        if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
 | 
				
			||||||
 | 
					            if let detail = apiError.detail, !detail.isEmpty {
 | 
				
			||||||
 | 
					                return detail
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if let message = apiError.data?.message, !message.isEmpty {
 | 
				
			||||||
 | 
					                return message
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let string = String(data: data, encoding: .utf8), !string.isEmpty {
 | 
				
			||||||
 | 
					            return string
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -384,6 +384,9 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Ищем пользователей…" : {
 | 
				
			||||||
 | 
					      "comment" : "Global search loading"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Как сбросить пароль?" : {
 | 
					    "Как сбросить пароль?" : {
 | 
				
			||||||
      "comment" : "FAQ question: reset password"
 | 
					      "comment" : "FAQ question: reset password"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
@ -539,6 +542,9 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Не удалось выполнить поиск." : {
 | 
				
			||||||
 | 
					      "comment" : "Search error fallback\nSearch service decoding error"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Не удалось загрузить список чатов." : {
 | 
					    "Не удалось загрузить список чатов." : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -559,6 +565,9 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Не удалось найти результаты." : {
 | 
				
			||||||
 | 
					      "comment" : "Search unexpected status"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Не удалось обновить пароль." : {
 | 
					    "Не удалось обновить пароль." : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -736,7 +745,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Ничего не найдено" : {
 | 
					    "Ничего не найдено" : {
 | 
				
			||||||
      "comment" : "Global search placeholder"
 | 
					      "comment" : "Global search empty state"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Новый пароль" : {
 | 
					    "Новый пароль" : {
 | 
				
			||||||
      "comment" : "Новый пароль",
 | 
					      "comment" : "Новый пароль",
 | 
				
			||||||
@ -853,6 +862,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Ошибка соединения с сервером." : {
 | 
					    "Ошибка соединения с сервером." : {
 | 
				
			||||||
 | 
					      "comment" : "Search error connection",
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
          "stringUnit" : {
 | 
					          "stringUnit" : {
 | 
				
			||||||
@ -966,6 +976,9 @@
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
    "Поиск" : {
 | 
					    "Поиск" : {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Поиск отменён." : {
 | 
				
			||||||
 | 
					      "comment" : "Search cancelled"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Пока что у вас нет чатов" : {
 | 
					    "Пока что у вас нет чатов" : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
@ -1056,6 +1069,9 @@
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Произошла неизвестная ошибка." : {
 | 
				
			||||||
 | 
					      "comment" : "Search unknown error"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Произошла ошибка." : {
 | 
					    "Произошла ошибка." : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -1143,6 +1159,9 @@
 | 
				
			|||||||
    "Связаться с разработчиками" : {
 | 
					    "Связаться с разработчиками" : {
 | 
				
			||||||
      "comment" : "FAQ: contact developers link"
 | 
					      "comment" : "FAQ: contact developers link"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Сервер вернул ошибку (%d)." : {
 | 
				
			||||||
 | 
					      "comment" : "Search error server status"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Сервер не отвечает. Попробуйте позже." : {
 | 
					    "Сервер не отвечает. Попробуйте позже." : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -1154,6 +1173,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Сессия истекла. Войдите снова." : {
 | 
					    "Сессия истекла. Войдите снова." : {
 | 
				
			||||||
 | 
					      "comment" : "Search error unauthorized",
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
          "stringUnit" : {
 | 
					          "stringUnit" : {
 | 
				
			||||||
 | 
				
			|||||||
@ -14,10 +14,15 @@ struct ChatsTab: View {
 | 
				
			|||||||
    var currentUserId: String?
 | 
					    var currentUserId: String?
 | 
				
			||||||
    @Binding var searchRevealProgress: CGFloat
 | 
					    @Binding var searchRevealProgress: CGFloat
 | 
				
			||||||
    @Binding var searchText: String
 | 
					    @Binding var searchText: String
 | 
				
			||||||
 | 
					    private let searchService = SearchService()
 | 
				
			||||||
    @StateObject private var viewModel = PrivateChatsViewModel()
 | 
					    @StateObject private var viewModel = PrivateChatsViewModel()
 | 
				
			||||||
    @State private var selectedChatId: String?
 | 
					    @State private var selectedChatId: String?
 | 
				
			||||||
    @State private var searchDragStartProgress: CGFloat = 0
 | 
					    @State private var searchDragStartProgress: CGFloat = 0
 | 
				
			||||||
    @State private var isSearchGestureActive: Bool = false
 | 
					    @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
 | 
					    private let searchRevealDistance: CGFloat = 90
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -32,10 +37,18 @@ struct ChatsTab: View {
 | 
				
			|||||||
            .background(Color(UIColor.systemBackground))
 | 
					            .background(Color(UIColor.systemBackground))
 | 
				
			||||||
            .onAppear {
 | 
					            .onAppear {
 | 
				
			||||||
                viewModel.loadInitialChats()
 | 
					                viewModel.loadInitialChats()
 | 
				
			||||||
 | 
					                handleSearchQueryChange(searchText)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
 | 
					            .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
 | 
				
			||||||
                viewModel.refresh()
 | 
					                viewModel.refresh()
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					            .onChange(of: searchText) { newValue in
 | 
				
			||||||
 | 
					                handleSearchQueryChange(newValue)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            .onDisappear {
 | 
				
			||||||
 | 
					                globalSearchTask?.cancel()
 | 
				
			||||||
 | 
					                globalSearchTask = nil
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @ViewBuilder
 | 
					    @ViewBuilder
 | 
				
			||||||
@ -93,13 +106,7 @@ struct ChatsTab: View {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                Section(header: globalSearchHeader) {
 | 
					                Section(header: globalSearchHeader) {
 | 
				
			||||||
                    Text(NSLocalizedString("Ничего не найдено", comment: "Global search placeholder"))
 | 
					                    globalSearchContent
 | 
				
			||||||
                        .font(.footnote)
 | 
					 | 
				
			||||||
                        .foregroundColor(.secondary)
 | 
					 | 
				
			||||||
                        .padding(.vertical, 12)
 | 
					 | 
				
			||||||
                        .frame(maxWidth: .infinity, alignment: .leading)
 | 
					 | 
				
			||||||
                        .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
 | 
					 | 
				
			||||||
                        .listRowSeparator(.hidden)
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                ForEach(viewModel.chats) { chat in
 | 
					                ForEach(viewModel.chats) { chat in
 | 
				
			||||||
@ -196,6 +203,21 @@ struct ChatsTab: View {
 | 
				
			|||||||
        Text(NSLocalizedString("Глобальный поиск", comment: "Global search section"))
 | 
					        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 {
 | 
					    private var emptySearchResultView: some View {
 | 
				
			||||||
        VStack(spacing: 8) {
 | 
					        VStack(spacing: 8) {
 | 
				
			||||||
            Image(systemName: "text.magnifyingglass")
 | 
					            Image(systemName: "text.magnifyingglass")
 | 
				
			||||||
@ -304,6 +326,79 @@ struct ChatsTab: View {
 | 
				
			|||||||
            viewModel.loadMoreIfNeeded(currentItem: chat)
 | 
					            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 {
 | 
					private extension ChatsTab {
 | 
				
			||||||
@ -312,6 +407,87 @@ private extension ChatsTab {
 | 
				
			|||||||
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
 | 
					        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
 | 
				
			||||||
#endif
 | 
					#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 {
 | 
					private struct ScrollDismissesKeyboardModifier: ViewModifier {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user