Compare commits
No commits in common. "7ce0fb3cd324e0b67a5634e1ff16ce048ef1b113" and "15ef27b42f68e7f7c2292ddeb5ca170ad253bd55" have entirely different histories.
7ce0fb3cd3
...
15ef27b42f
@ -1,111 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
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,9 +384,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ищем пользователей…" : {
|
|
||||||
"comment" : "Global search loading"
|
|
||||||
},
|
|
||||||
"Как сбросить пароль?" : {
|
"Как сбросить пароль?" : {
|
||||||
"comment" : "FAQ question: reset password"
|
"comment" : "FAQ question: reset password"
|
||||||
},
|
},
|
||||||
@ -542,9 +539,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Не удалось выполнить поиск." : {
|
|
||||||
"comment" : "Search error fallback\nSearch service decoding error"
|
|
||||||
},
|
|
||||||
"Не удалось загрузить список чатов." : {
|
"Не удалось загрузить список чатов." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -565,9 +559,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Не удалось найти результаты." : {
|
|
||||||
"comment" : "Search unexpected status"
|
|
||||||
},
|
|
||||||
"Не удалось обновить пароль." : {
|
"Не удалось обновить пароль." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -745,7 +736,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ничего не найдено" : {
|
"Ничего не найдено" : {
|
||||||
"comment" : "Global search empty state"
|
"comment" : "Global search placeholder"
|
||||||
},
|
},
|
||||||
"Новый пароль" : {
|
"Новый пароль" : {
|
||||||
"comment" : "Новый пароль",
|
"comment" : "Новый пароль",
|
||||||
@ -862,7 +853,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ошибка соединения с сервером." : {
|
"Ошибка соединения с сервером." : {
|
||||||
"comment" : "Search error connection",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -976,9 +966,6 @@
|
|||||||
},
|
},
|
||||||
"Поиск" : {
|
"Поиск" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Поиск отменён." : {
|
|
||||||
"comment" : "Search cancelled"
|
|
||||||
},
|
},
|
||||||
"Пока что у вас нет чатов" : {
|
"Пока что у вас нет чатов" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1069,9 +1056,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Произошла неизвестная ошибка." : {
|
|
||||||
"comment" : "Search unknown error"
|
|
||||||
},
|
|
||||||
"Произошла ошибка." : {
|
"Произошла ошибка." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1159,9 +1143,6 @@
|
|||||||
"Связаться с разработчиками" : {
|
"Связаться с разработчиками" : {
|
||||||
"comment" : "FAQ: contact developers link"
|
"comment" : "FAQ: contact developers link"
|
||||||
},
|
},
|
||||||
"Сервер вернул ошибку (%d)." : {
|
|
||||||
"comment" : "Search error server status"
|
|
||||||
},
|
|
||||||
"Сервер не отвечает. Попробуйте позже." : {
|
"Сервер не отвечает. Попробуйте позже." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1173,7 +1154,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Сессия истекла. Войдите снова." : {
|
"Сессия истекла. Войдите снова." : {
|
||||||
"comment" : "Search error unauthorized",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -14,15 +14,10 @@ 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
|
||||||
|
|
||||||
@ -37,18 +32,10 @@ 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
|
||||||
@ -106,7 +93,13 @@ struct ChatsTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section(header: globalSearchHeader) {
|
Section(header: globalSearchHeader) {
|
||||||
globalSearchContent
|
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)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.chats) { chat in
|
ForEach(viewModel.chats) { chat in
|
||||||
@ -203,21 +196,6 @@ 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")
|
||||||
@ -326,79 +304,6 @@ 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 {
|
||||||
@ -407,87 +312,6 @@ 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