266 lines
9.7 KiB
Swift
266 lines
9.7 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|