Compare commits

..

3 Commits

Author SHA1 Message Date
7ce0fb3cd3 patch name 2025-10-07 05:41:01 +03:00
a5b2c2702c add patch to search 2025-10-07 05:35:10 +03:00
26b8dfc71a connect to search 2025-10-07 05:24:35 +03:00
4 changed files with 486 additions and 8 deletions

View 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
}
}

View 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
}
}

View File

@ -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" : {

View File

@ -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 {