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