173 lines
5.4 KiB
Swift
173 lines
5.4 KiB
Swift
import Combine
|
|
import Foundation
|
|
import os
|
|
|
|
// MARK: - SearchViewModel
|
|
|
|
/// Search view model with **cached** state.
|
|
///
|
|
/// Uses `ObservableObject` + `@Published` (NOT `@Observable`) to avoid
|
|
/// SwiftUI observation feedback loops when embedded inside a NavigationStack
|
|
/// within the tab pager. `@Observable` + `@State` caused infinite body
|
|
/// re-evaluations of SearchView (hundreds per second → 99 % CPU freeze).
|
|
/// `ObservableObject` + `@StateObject` matches ChatListViewModel and
|
|
/// SettingsViewModel which are both stable.
|
|
@MainActor
|
|
final class SearchViewModel: ObservableObject {
|
|
|
|
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search")
|
|
|
|
// MARK: - State
|
|
|
|
@Published var searchQuery = ""
|
|
|
|
@Published private(set) var searchResults: [SearchUser] = []
|
|
@Published private(set) var isSearching = false
|
|
@Published private(set) var recentSearches: [RecentSearch] = []
|
|
|
|
private var searchTask: Task<Void, Never>?
|
|
private var lastSearchedText = ""
|
|
private var searchHandlerToken: UUID?
|
|
private var recentSearchesCancellable: AnyCancellable?
|
|
private let recentRepository = RecentSearchesRepository.shared
|
|
private let searchDispatcher: SearchResultDispatching
|
|
|
|
// MARK: - Init
|
|
|
|
init(searchDispatcher: SearchResultDispatching? = nil) {
|
|
self.searchDispatcher = searchDispatcher ?? LiveSearchResultDispatcher()
|
|
configureRecentSearches()
|
|
setupSearchCallback()
|
|
}
|
|
|
|
// MARK: - Search Logic
|
|
|
|
func setSearchQuery(_ query: String) {
|
|
searchQuery = SearchParityPolicy.sanitizeInput(query)
|
|
onSearchQueryChanged()
|
|
}
|
|
|
|
private func onSearchQueryChanged() {
|
|
searchTask?.cancel()
|
|
searchTask = nil
|
|
|
|
let normalized = SearchParityPolicy.normalizedQuery(searchQuery)
|
|
if normalized.isEmpty {
|
|
searchResults = []
|
|
isSearching = false
|
|
lastSearchedText = ""
|
|
return
|
|
}
|
|
|
|
if normalized == lastSearchedText {
|
|
return
|
|
}
|
|
|
|
isSearching = true
|
|
|
|
// Debounce 1 second (like Android)
|
|
searchTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(1))
|
|
|
|
guard let self, !Task.isCancelled else {
|
|
return
|
|
}
|
|
|
|
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
|
guard !currentQuery.isEmpty, currentQuery == normalized else {
|
|
return
|
|
}
|
|
|
|
let connState = self.searchDispatcher.connectionState
|
|
let hash = SessionManager.shared.privateKeyHash ?? self.searchDispatcher.privateHash
|
|
|
|
guard connState == .authenticated, let hash else {
|
|
self.isSearching = false
|
|
return
|
|
}
|
|
|
|
self.lastSearchedText = currentQuery
|
|
|
|
var packet = PacketSearch()
|
|
packet.privateKey = hash
|
|
packet.search = currentQuery
|
|
|
|
self.searchDispatcher.sendSearchPacket(packet)
|
|
}
|
|
}
|
|
|
|
func clearSearch() {
|
|
searchQuery = ""
|
|
searchResults = []
|
|
isSearching = false
|
|
lastSearchedText = ""
|
|
searchTask?.cancel()
|
|
searchTask = nil
|
|
}
|
|
|
|
// MARK: - Search Callback
|
|
|
|
private func setupSearchCallback() {
|
|
if let token = searchHandlerToken {
|
|
searchDispatcher.removeSearchResultHandler(token)
|
|
}
|
|
|
|
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
|
guard !query.isEmpty else {
|
|
self.isSearching = false
|
|
return
|
|
}
|
|
|
|
let localMatches = SearchParityPolicy.localAugmentedUsers(
|
|
query: query,
|
|
currentPublicKey: SessionManager.shared.currentPublicKey,
|
|
dialogs: Array(DialogRepository.shared.dialogs.values)
|
|
)
|
|
self.searchResults = SearchParityPolicy.mergeServerAndLocal(
|
|
server: packet.users,
|
|
local: localMatches
|
|
)
|
|
self.isSearching = false
|
|
|
|
// Update dialog info from server results
|
|
for user in packet.users {
|
|
DialogRepository.shared.updateUserInfo(
|
|
publicKey: user.publicKey,
|
|
title: user.title,
|
|
username: user.username,
|
|
verified: user.verified,
|
|
online: user.online
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent Searches
|
|
|
|
func addToRecent(_ user: SearchUser) {
|
|
recentRepository.add(user)
|
|
}
|
|
|
|
func removeRecentSearch(publicKey: String) {
|
|
recentRepository.remove(publicKey: publicKey)
|
|
}
|
|
|
|
func clearRecentSearches() {
|
|
recentRepository.clearAll()
|
|
}
|
|
|
|
private func configureRecentSearches() {
|
|
recentRepository.setAccount(SessionManager.shared.currentPublicKey)
|
|
recentSearches = recentRepository.recentSearches
|
|
recentSearchesCancellable = recentRepository.$recentSearches
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] list in
|
|
self?.recentSearches = list
|
|
}
|
|
}
|
|
}
|