// // TransactionsManagerStore.swift // Zashi // // Created by Lukáš Korba on 01-22-2025. // import ComposableArchitecture import SwiftUI import ZcashLightClientKit import Utils import Models import Generated import Pasteboard import SDKSynchronizer import ReadTransactionsStorage import ZcashSDKEnvironment import AddressBookClient import UIComponents import AddressBook import NumberFormatter import UserMetadataProvider @Reducer public struct TransactionsManager { public struct Section: Equatable, Identifiable { public let id: String public var latestTransactionId = "" let timestamp: TimeInterval public let transactions: IdentifiedArrayOf } public enum Filter: Equatable { case bookmarked case contact case memos case notes case received case sent case unread } @ObservableState public struct State: Equatable { public var CancelId = UUID() public var activeFilters: [Filter] = [] @Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty public var filteredTransactionsList: IdentifiedArrayOf = [] public var filtersRequest = false public var isInvalidated = true public var searchedTransactionsList: IdentifiedArrayOf = [] public var searchTerm = "" public var selectedFilters: [Filter] = [] @Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil @Shared(.inMemory(.transactions)) public var transactions: IdentifiedArrayOf = [] public var transactionSections: [Section] = [] @Shared(.inMemory(.zashiWalletAccount)) public var zashiWalletAccount: WalletAccount? = nil public var isBookmarkedFilterActive: Bool { selectedFilters.contains(.bookmarked) } public var isContactFilterActive: Bool { selectedFilters.contains(.contact) } public var isMemosFilterActive: Bool { selectedFilters.contains(.memos) } public var isNotesFilterActive: Bool { selectedFilters.contains(.notes) } public var isReceivedFilterActive: Bool { selectedFilters.contains(.received) } public var isSentFilterActive: Bool { selectedFilters.contains(.sent) } public var isUnreadFilterActive: Bool { selectedFilters.contains(.unread) } public init() { } } public enum Action: BindableAction, Equatable { case asynchronousMemoSearchResult([String]) case applyFiltersTapped case binding(BindingAction) case dismissRequired case eraseSearchTermTapped case filterTapped case onAppear case resetFiltersTapped case toggleFilter(Filter) case transactionsUpdated case transactionTapped(String) case updateTransactionPeriods case updateTransactionsAccordingToFilters case updateTransactionsAccordingToSearchTerm } @Dependency(\.addressBook) var addressBook @Dependency(\.mainQueue) var mainQueue @Dependency(\.numberFormatter) var numberFormatter @Dependency(\.sdkSynchronizer) var sdkSynchronizer @Dependency(\.userMetadataProvider) var userMetadataProvider @Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment public init() { } public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .onAppear: return .publisher { state.$transactions.publisher .map { _ in TransactionsManager.Action.transactionsUpdated } } .cancellable(id: state.CancelId, cancelInFlight: true) case .binding(\.searchTerm): return .send(.updateTransactionsAccordingToSearchTerm) case .binding: return .none case .dismissRequired: return .none case .applyFiltersTapped: state.activeFilters = state.selectedFilters state.filtersRequest = false return .send(.updateTransactionsAccordingToSearchTerm) case .resetFiltersTapped: state.selectedFilters.removeAll() state.activeFilters.removeAll() return .send(.updateTransactionsAccordingToSearchTerm) case .eraseSearchTermTapped: state.searchTerm = "" return .send(.updateTransactionsAccordingToSearchTerm) case .filterTapped: state.selectedFilters = state.activeFilters state.filtersRequest = true return .none case .toggleFilter(let filter): if state.selectedFilters.contains(filter) { state.selectedFilters.removeAll { $0 == filter } } else { state.selectedFilters.append(filter) } return .none case .transactionTapped(let txId): if let index = state.transactions.index(id: txId) { if TransactionsManager.isUnread(state.transactions[index]) { userMetadataProvider.readTx(txId) if let account = state.selectedWalletAccount?.account { try? userMetadataProvider.store(account) } } } return .none case .transactionsUpdated: state.isInvalidated = false return .send(.updateTransactionsAccordingToSearchTerm) case .updateTransactionsAccordingToSearchTerm: if !state.searchTerm.isEmpty && state.searchTerm.count >= 2 { state.searchedTransactionsList.removeAll() // synchronous search state.transactions.forEach { transaction in if checkSearchTerm(state.searchTerm, transaction: transaction, addressBookContacts: state.addressBookContacts) { state.searchedTransactionsList.append(transaction) } } // asynchronous search return .run { [searchTerm = state.searchTerm] send in let txids = try? await sdkSynchronizer.fetchTxidsWithMemoContaining(searchTerm).map { $0.toHexStringTxId() } if let txids { await send(.asynchronousMemoSearchResult(txids)) } else { await send(.updateTransactionsAccordingToFilters) } } } else { state.searchedTransactionsList = state.transactions } return .send(.updateTransactionsAccordingToFilters) case .asynchronousMemoSearchResult(let txids): let results = state.transactions.filter { txids.contains($0.id) } state.searchedTransactionsList.append(contentsOf: results) return .send(.updateTransactionsAccordingToFilters) case .updateTransactionsAccordingToFilters: // modify the initial list of all transactions according to active filters if !state.activeFilters.isEmpty { state.filteredTransactionsList.removeAll() state.searchedTransactionsList.forEach { transaction in var isFilteredOut = false for i in 0..(uniqueElements: transactions) ) } let sortedSections = sections.sorted { lhs, rhs in lhs.timestamp > rhs.timestamp } sortedSections.forEach { section in state.transactionSections.append(section) } return .none } } } } extension TransactionsManager { func getTimePeriod(for date: Date, now: Date) -> String { let calendar = Calendar.current let components = calendar.dateComponents([.day], from: date, to: now) let daysAgo = components.day ?? Int.max if Calendar.current.isDateInToday(date) { return L10n.Filter.today } else if Calendar.current.isDateInYesterday(date) { return L10n.Filter.yesterday } else if daysAgo < 7 { return L10n.Filter.previous7days } else if daysAgo < 31 { return L10n.Filter.previous30days } else { let formatter = DateFormatter() formatter.dateFormat = "MMMM yyyy" return formatter.string(from: date) } } func unicodeContains(_ searchTerm: String, in text: String) -> Bool { let normalizedText = text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) let normalizedSearchTerm = searchTerm.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) return normalizedText.range(of: normalizedSearchTerm) != nil } func checkSearchTerm(_ searchTerm: String, transaction: TransactionState, addressBookContacts: AddressBookContacts) -> Bool { // search contact name if addressBookContacts.contacts.contains(where: { $0.id == transaction.address && unicodeContains(searchTerm, in: $0.name) }) { return true } // search address if unicodeContains(searchTerm, in: transaction.address) { return true } // Regex amounts var input = transaction.zecAmount.decimalString() if transaction.isSpending { input = "-\(input)" } let pattern = "([<>])\\s*(-?(?:0|(?=\\.))?\\d*(?:[.,]\\d+)?)" if let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: searchTerm, range: NSRange(searchTerm.startIndex..., in: searchTerm)) { if let operatorRange = Range(match.range(at: 1), in: searchTerm), let numberRange = Range(match.range(at: 2), in: searchTerm), let threshold = numberFormatter.number(String(searchTerm[numberRange])) { let op = String(searchTerm[operatorRange]) if let amount = numberFormatter.number(input) { if op == "<" { return amount.doubleValue < threshold.doubleValue } else if op == ">" { return amount.doubleValue > threshold.doubleValue } } } } // fullsearch amounts if input.contains(searchTerm) { return true } // fullsearch annotations if let annotation = userMetadataProvider.annotationFor(transaction.id), annotation.contains(searchTerm) { return true } return false } } extension TransactionsManager.Filter { func applyFilter( _ transaction: TransactionState, addressBookContacts: AddressBookContacts, userMetadataProvider: UserMetadataProviderClient ) -> Bool { switch self { case .bookmarked: return userMetadataProvider.isBookmarked(transaction.id) case .contact: return addressBookContacts.contacts.contains(where: { $0.id == transaction.address }) case .memos: return transaction.memoCount > 0 case .notes: return userMetadataProvider.annotationFor(transaction.id) != nil case .received: return !transaction.isSentTransaction case .sent: return transaction.isSentTransaction case .unread: return true } } } public extension TransactionsManager { static func isUnread(_ transaction: TransactionState) -> Bool { guard !transaction.isSentTransaction else { return false } guard !transaction.isShieldingTransaction else { return false } guard transaction.memoCount > 0 else { return false } @Dependency(\.userMetadataProvider) var userMetadataProvider return !userMetadataProvider.isRead(transaction.id, transaction.timestamp) } }