Files
mobile-ios/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift

266 lines
9.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Combine
import Foundation
import os
// MARK: - Dialogs Mode (All vs Requests)
/// Desktop parity: dialogs are split into "All" (iHaveSent) and "Requests" (only incoming).
enum DialogsMode: Hashable { case all, requests }
// MARK: - ChatListViewModel
@MainActor
final class ChatListViewModel: ObservableObject {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
// MARK: - State
@Published var isLoading = false
@Published var dialogsMode: DialogsMode = .all
/// NOT @Published avoids 2× body re-renders per keystroke in ChatListView.
/// Local filtering uses `searchText` param directly in ChatListSearchContent.
var searchQuery = ""
@Published var serverSearchResults: [SearchUser] = []
@Published var isServerSearching = false
@Published var recentSearches: [RecentSearch] = []
private var searchTask: Task<Void, Never>?
private var searchRetryTask: Task<Void, Never>?
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: - Dialog partitions (single pass, cached per observation cycle)
private struct DialogPartition {
var allPinned: [Dialog] = []
var allUnpinned: [Dialog] = []
var requests: [Dialog] = []
var totalUnread: Int = 0
}
/// Cached partition computed once, reused by all properties until dialogs change.
private var _cachedPartition: DialogPartition?
private var _cachedPartitionVersion: Int = -1
private var partition: DialogPartition {
let repo = DialogRepository.shared
let currentVersion = repo.dialogsVersion
if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion {
return cached
}
var result = DialogPartition()
for dialog in repo.sortedDialogs {
let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey)
if isChat {
if dialog.isPinned {
result.allPinned.append(dialog)
} else {
result.allUnpinned.append(dialog)
}
} else {
result.requests.append(dialog)
}
if !dialog.isMuted {
result.totalUnread += dialog.unreadCount
}
}
_cachedPartition = result
_cachedPartitionVersion = currentVersion
return result
}
var filteredDialogs: [Dialog] {
let p = partition
switch dialogsMode {
case .all: return p.allPinned + p.allUnpinned
case .requests: return p.requests
}
}
var pinnedDialogs: [Dialog] { partition.allPinned }
var unpinnedDialogs: [Dialog] { partition.allUnpinned }
var requestsCount: Int { partition.requests.count }
var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int { partition.totalUnread }
var hasUnread: Bool { totalUnreadCount > 0 }
// MARK: - Per-mode dialogs (for TabView pages)
var allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
var allModePinned: [Dialog] { partition.allPinned }
var allModeUnpinned: [Dialog] { partition.allUnpinned }
var requestsModeDialogs: [Dialog] { partition.requests }
// MARK: - Actions
func setSearchQuery(_ query: String) {
searchQuery = SearchParityPolicy.sanitizeInput(query)
triggerServerSearch()
}
func deleteDialog(_ dialog: Dialog) {
MessageRepository.shared.deleteDialog(dialog.opponentKey)
DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey)
}
func togglePin(_ dialog: Dialog) {
DialogRepository.shared.togglePin(opponentKey: dialog.opponentKey)
}
func toggleMute(_ dialog: Dialog) {
DialogRepository.shared.toggleMute(opponentKey: dialog.opponentKey)
}
func markAsRead(_ dialog: Dialog) {
DialogRepository.shared.markAsRead(opponentKey: dialog.opponentKey)
}
// MARK: - Server Search
private func setupSearchCallback() {
if let token = searchHandlerToken {
searchDispatcher.removeSearchResultHandler(token)
}
Self.logger.debug("Setting up search callback")
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")
return
}
guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
self.isServerSearching = false
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) 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 {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified,
online: user.online
)
}
}
}
}
private func triggerServerSearch() {
searchTask?.cancel()
searchTask = nil
searchRetryTask?.cancel()
searchRetryTask = nil
let trimmed = SearchParityPolicy.normalizedQuery(searchQuery)
if trimmed.isEmpty {
// Guard: only publish if value actually changes (avoids extra re-renders)
if !serverSearchResults.isEmpty { serverSearchResults = [] }
if isServerSearching { isServerSearching = false }
return
}
// Guard: don't re-publish true when already true
if !isServerSearching { isServerSearching = true }
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 == trimmed else { return }
self.sendSearchPacket(query: currentQuery)
}
}
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
private func sendSearchPacket(query: String) {
let connState = searchDispatcher.connectionState
let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash
guard connState == .authenticated, let hash else {
// Not authenticated wait for reconnect then send
Self.logger.debug("Search deferred — waiting for authentication")
searchRetryTask?.cancel()
searchRetryTask = Task { [weak self] in
// Poll every 500ms for up to 10s (covers 5s reconnect + handshake)
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(500))
guard let self, !Task.isCancelled else { return }
let current = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard current == query else { return } // Query changed, abort
if self.searchDispatcher.connectionState == .authenticated {
Self.logger.debug("Connection restored — sending pending search")
self.sendSearchPacket(query: query)
return
}
}
// Timed out
guard let self else { return }
Self.logger.warning("Search timed out waiting for authentication")
self.isServerSearching = false
}
return
}
var packet = PacketSearch()
packet.privateKey = hash
packet.search = query
Self.logger.debug("📤 Sending search packet for '\(query)'")
searchDispatcher.sendSearchPacket(packet)
}
// 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
}
}
}