Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит

This commit is contained in:
2026-03-28 18:21:55 +05:00
parent 8314318a8a
commit 5af28b68a8
40 changed files with 3990 additions and 892 deletions

View File

@@ -24,27 +24,17 @@ struct ChatListSearchContent: View {
private extension ChatListSearchContent {
/// Desktop-parity: skeleton empty results only one visible at a time.
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
/// to avoid @Published re-render cascade through ChatListView.
/// Uses unified search policy from ChatListViewModel callback merge.
@ViewBuilder
var activeSearchContent: some View {
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
// Local results: match by username ONLY (desktop parity server matches usernames)
let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in
!query.isEmpty && dialog.opponentUsername.lowercased().contains(query)
}
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
}
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
let hasAnyResult = !viewModel.serverSearchResults.isEmpty
if viewModel.isServerSearching && !hasAnyResult {
SearchSkeletonView()
} else if !viewModel.isServerSearching && !hasAnyResult {
noResultsState
} else {
resultsList(localResults: localResults, serverOnly: serverOnly)
resultsList(results: viewModel.serverSearchResults)
}
}
@@ -68,23 +58,14 @@ private extension ChatListSearchContent {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable list of local dialogs + server results.
/// Scrollable list of merged search results.
/// Shows skeleton rows at the bottom while server is still searching.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
func resultsList(results: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(localResults) { dialog in
Button {
onOpenDialog(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
.buttonStyle(.plain)
}
ForEach(serverOnly, id: \.publicKey) { user in
ForEach(results, id: \.publicKey) { user in
serverUserRow(user)
if user.publicKey != serverOnly.last?.publicKey {
if user.publicKey != results.last?.publicKey {
Divider()
.padding(.leading, 76)
.foregroundStyle(RosettaColors.Adaptive.divider)

View File

@@ -30,10 +30,12 @@ final class ChatListViewModel: ObservableObject {
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
private let recentRepository = RecentSearchesRepository.shared
private let searchDispatcher: SearchResultDispatching
// MARK: - Init
init() {
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
self.searchDispatcher = searchDispatcher
configureRecentSearches()
setupSearchCallback()
}
@@ -107,7 +109,7 @@ final class ChatListViewModel: ObservableObject {
// MARK: - Actions
func setSearchQuery(_ query: String) {
searchQuery = normalizeSearchInput(query)
searchQuery = SearchParityPolicy.sanitizeInput(query)
triggerServerSearch()
}
@@ -132,11 +134,11 @@ final class ChatListViewModel: ObservableObject {
private func setupSearchCallback() {
if let token = searchHandlerToken {
ProtocolManager.shared.removeSearchResultHandler(token)
searchDispatcher.removeSearchResultHandler(token)
}
Self.logger.debug("Setting up search callback")
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.logger.debug("Search callback: self is nil")
@@ -147,7 +149,16 @@ final class ChatListViewModel: ObservableObject {
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
self.serverSearchResults = packet.users
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
let localMatches = SearchParityPolicy.localAugmentedUsers(
query: query,
currentPublicKey: SessionManager.shared.currentPublicKey,
dialogs: Array(DialogRepository.shared.dialogs.values)
)
self.serverSearchResults = SearchParityPolicy.mergeServerAndLocal(
server: packet.users,
local: localMatches
)
self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
for user in packet.users {
@@ -169,7 +180,7 @@ final class ChatListViewModel: ObservableObject {
searchRetryTask?.cancel()
searchRetryTask = nil
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
let trimmed = SearchParityPolicy.normalizedQuery(searchQuery)
if trimmed.isEmpty {
// Guard: only publish if value actually changes (avoids extra re-renders)
if !serverSearchResults.isEmpty { serverSearchResults = [] }
@@ -184,7 +195,7 @@ final class ChatListViewModel: ObservableObject {
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
self.sendSearchPacket(query: currentQuery)
@@ -193,8 +204,8 @@ final class ChatListViewModel: ObservableObject {
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
private func sendSearchPacket(query: String) {
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
let connState = searchDispatcher.connectionState
let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash
guard connState == .authenticated, let hash else {
// Not authenticated wait for reconnect then send
@@ -205,9 +216,9 @@ final class ChatListViewModel: ObservableObject {
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(500))
guard let self, !Task.isCancelled else { return }
let current = self.searchQuery.trimmingCharacters(in: .whitespaces)
let current = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard current == query else { return } // Query changed, abort
if ProtocolManager.shared.connectionState == .authenticated {
if self.searchDispatcher.connectionState == .authenticated {
Self.logger.debug("Connection restored — sending pending search")
self.sendSearchPacket(query: query)
return
@@ -223,14 +234,9 @@ final class ChatListViewModel: ObservableObject {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = query.lowercased()
packet.search = query
Self.logger.debug("📤 Sending search packet for '\(query)'")
ProtocolManager.shared.sendPacket(packet)
}
private func normalizeSearchInput(_ input: String) -> String {
input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
searchDispatcher.sendSearchPacket(packet)
}
// MARK: - Recent Searches