Files
mobile-ios/Rosetta/Features/Chats/Search/SearchViewModel.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
}
}
}