Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user