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"
|
||||
},
|
||||
@ -539,6 +542,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Не удалось выполнить поиск." : {
|
||||
"comment" : "Search error fallback\nSearch service decoding error"
|
||||
},
|
||||
"Не удалось загрузить список чатов." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -559,6 +565,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Не удалось найти результаты." : {
|
||||
"comment" : "Search unexpected status"
|
||||
},
|
||||
"Не удалось обновить пароль." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -736,7 +745,7 @@
|
||||
}
|
||||
},
|
||||
"Ничего не найдено" : {
|
||||
"comment" : "Global search placeholder"
|
||||
"comment" : "Global search empty state"
|
||||
},
|
||||
"Новый пароль" : {
|
||||
"comment" : "Новый пароль",
|
||||
@ -853,6 +862,7 @@
|
||||
}
|
||||
},
|
||||
"Ошибка соединения с сервером." : {
|
||||
"comment" : "Search error connection",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -966,6 +976,9 @@
|
||||
},
|
||||
"Поиск" : {
|
||||
|
||||
},
|
||||
"Поиск отменён." : {
|
||||
"comment" : "Search cancelled"
|
||||
},
|
||||
"Пока что у вас нет чатов" : {
|
||||
"localizations" : {
|
||||
@ -1056,6 +1069,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Произошла неизвестная ошибка." : {
|
||||
"comment" : "Search unknown error"
|
||||
},
|
||||
"Произошла ошибка." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1143,6 +1159,9 @@
|
||||
"Связаться с разработчиками" : {
|
||||
"comment" : "FAQ: contact developers link"
|
||||
},
|
||||
"Сервер вернул ошибку (%d)." : {
|
||||
"comment" : "Search error server status"
|
||||
},
|
||||
"Сервер не отвечает. Попробуйте позже." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1154,6 +1173,7 @@
|
||||
}
|
||||
},
|
||||
"Сессия истекла. Войдите снова." : {
|
||||
"comment" : "Search error unauthorized",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@ -14,10 +14,15 @@ 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
|
||||
|
||||
@ -32,10 +37,18 @@ struct ChatsTab: View {
|
||||
.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
|
||||
@ -93,13 +106,7 @@ struct ChatsTab: View {
|
||||
}
|
||||
|
||||
Section(header: globalSearchHeader) {
|
||||
Text(NSLocalizedString("Ничего не найдено", comment: "Global search placeholder"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.chats) { chat in
|
||||
@ -196,6 +203,21 @@ struct ChatsTab: 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")
|
||||
@ -304,6 +326,79 @@ struct ChatsTab: View {
|
||||
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 {
|
||||
@ -312,6 +407,87 @@ private extension ChatsTab {
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user