From 99af24c39e4af1c129c238f9e286158a4673d509 Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Wed, 19 Jun 2024 09:43:56 -0300 Subject: [PATCH] feat(@desktop/wallet): integrate collectibles search backend Closes #13922 --- .../collectibles_search/io_interface.nim | 28 ++ .../collectibles_search/module.nim | 71 ++++++ .../collectibles_search/view.nim | 36 +++ .../main/wallet_section/io_interface.nim | 3 + .../modules/main/wallet_section/module.nim | 11 + src/app/modules/shared/wallet_utils.nim | 11 + .../shared_models/collectibles_data_entry.nim | 216 ++++++++++++++++ .../shared_models/collectibles_data_model.nim | 240 ++++++++++++++++++ .../shared_models/collectibles_entry.nim | 11 +- .../shared_models/collections_data_entry.nim | 140 ++++++++++ .../shared_models/collections_data_model.nim | 230 +++++++++++++++++ .../collectibles_search/controller.nim | 218 ++++++++++++++++ .../collectibles_search/events_handler.nim | 54 ++++ .../collections_search/controller.nim | 206 +++++++++++++++ .../collections_search/events_handler.nim | 54 ++++ src/backend/collectibles.nim | 120 +++++++++ src/backend/collectibles_types.nim | 40 ++- .../Wallet/stores/CollectiblesStore.qml | 23 ++ vendor/status-go | 2 +- 19 files changed, 1702 insertions(+), 12 deletions(-) create mode 100644 src/app/modules/main/wallet_section/collectibles_search/io_interface.nim create mode 100644 src/app/modules/main/wallet_section/collectibles_search/module.nim create mode 100644 src/app/modules/main/wallet_section/collectibles_search/view.nim create mode 100644 src/app/modules/shared_models/collectibles_data_entry.nim create mode 100644 src/app/modules/shared_models/collectibles_data_model.nim create mode 100644 src/app/modules/shared_models/collections_data_entry.nim create mode 100644 src/app/modules/shared_models/collections_data_model.nim create mode 100644 src/app/modules/shared_modules/collectibles_search/controller.nim create mode 100644 src/app/modules/shared_modules/collectibles_search/events_handler.nim create mode 100644 src/app/modules/shared_modules/collections_search/controller.nim create mode 100644 src/app/modules/shared_modules/collections_search/events_handler.nim diff --git a/src/app/modules/main/wallet_section/collectibles_search/io_interface.nim b/src/app/modules/main/wallet_section/collectibles_search/io_interface.nim new file mode 100644 index 000000000..dca6f34ff --- /dev/null +++ b/src/app/modules/main/wallet_section/collectibles_search/io_interface.nim @@ -0,0 +1,28 @@ +import app/modules/shared_modules/collectibles_search/controller as collectibles_search_c +import app/modules/shared_modules/collections_search/controller as collections_search_c + + +type + AccessInterface* {.pure inheritable.} = ref object of RootObj + ## Abstract class for any input/interaction with this module. + +method delete*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method load*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method isLoaded*(self: AccessInterface): bool {.base.} = + raise newException(ValueError, "No implementation available") + +method getCollectiblesSearchController*(self: AccessInterface): collectibles_search_c.Controller {.base.} = + raise newException(ValueError, "No implementation available") + +method getCollectionsSearchController*(self: AccessInterface): collections_search_c.Controller {.base.} = + raise newException(ValueError, "No implementation available") + +# View Delegate Interface +# Delegate for the view must be declared here due to use of QtObject and multi +# inheritance, which is not well supported in Nim. +method viewDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/wallet_section/collectibles_search/module.nim b/src/app/modules/main/wallet_section/collectibles_search/module.nim new file mode 100644 index 000000000..bde4b8a3e --- /dev/null +++ b/src/app/modules/main/wallet_section/collectibles_search/module.nim @@ -0,0 +1,71 @@ +import NimQml + +import ./io_interface, ./view +import ../io_interface as delegate_interface + +import app/global/global_singleton +import app/core/eventemitter +import app/modules/shared_modules/collectibles_search/controller as collectibles_c +import app/modules/shared_modules/collections_search/controller as collections_c +import app_service/service/network/service as network_service + +import backend/collectibles as backend_collectibles + +export io_interface + +type + Module* = ref object of io_interface.AccessInterface + delegate: delegate_interface.AccessInterface + events: EventEmitter + view: View + collectiblesController: collectibles_c.Controller + collectionsController: collections_c.Controller + moduleLoaded: bool + +proc newModule*( + delegate: delegate_interface.AccessInterface, + events: EventEmitter, + networkService: network_service.Service +): Module = + result = Module() + result.delegate = delegate + result.events = events + + let collectiblesController = collectibles_c.newController( + requestId = int32(backend_collectibles.CollectiblesRequestID.Search), + networkService = networkService, + events = events + ) + result.collectiblesController = collectiblesController + + let collectionsController = collections_c.newController( + requestId = int32(backend_collectibles.CollectiblesRequestID.Search), + networkService = networkService, + events = events + ) + result.collectionsController = collectionsController + + result.view = newView(result) + result.moduleLoaded = false + +method delete*(self: Module) = + self.view.delete + self.collectionsController.delete + self.collectiblesController.delete + +method load*(self: Module) = + singletonInstance.engine.setRootContextProperty("walletSectionCollectiblesSearch", newQVariant(self.view)) + self.view.load() + +method isLoaded*(self: Module): bool = + return self.moduleLoaded + +method viewDidLoad*(self: Module) = + self.moduleLoaded = true + self.delegate.searchCollectiblesModuleDidLoad() + +method getCollectiblesSearchController*(self: Module): collectibles_c.Controller = + return self.collectiblesController + +method getCollectionsSearchController*(self: Module): collections_c.Controller = + return self.collectionsController diff --git a/src/app/modules/main/wallet_section/collectibles_search/view.nim b/src/app/modules/main/wallet_section/collectibles_search/view.nim new file mode 100644 index 000000000..433b73e25 --- /dev/null +++ b/src/app/modules/main/wallet_section/collectibles_search/view.nim @@ -0,0 +1,36 @@ +import NimQml, sequtils, strutils + +import ./io_interface + +import app/modules/shared_modules/collectibles_search/controller as collectibles_search_c +import app/modules/shared_modules/collections_search/controller as collections_search_c + +QtObject: + type + View* = ref object of QObject + delegate: io_interface.AccessInterface + collectiblesSearchController: collectibles_search_c.Controller + collectionsSearchController: collections_search_c.Controller + + proc delete*(self: View) = + self.QObject.delete + + proc newView*(delegate: io_interface.AccessInterface): View = + new(result, delete) + result.QObject.setup + result.delegate = delegate + result.collectiblesSearchController = delegate.getCollectiblesSearchController() + result.collectionsSearchController = delegate.getCollectionsSearchController() + + proc load*(self: View) = + self.delegate.viewDidLoad() + + proc getCollectiblesSearchController(self: View): QVariant {.slot.} = + return newQVariant(self.collectiblesSearchController) + QtProperty[QVariant] collectiblesSearchController: + read = getCollectiblesSearchController + + proc getCollectionsSearchController(self: View): QVariant {.slot.} = + return newQVariant(self.collectionsSearchController) + QtProperty[QVariant] collectionsSearchController: + read = getCollectionsSearchController diff --git a/src/app/modules/main/wallet_section/io_interface.nim b/src/app/modules/main/wallet_section/io_interface.nim index 845d2005c..0785fab29 100644 --- a/src/app/modules/main/wallet_section/io_interface.nim +++ b/src/app/modules/main/wallet_section/io_interface.nim @@ -49,6 +49,9 @@ method allTokensModuleDidLoad*(self: AccessInterface) {.base.} = method allCollectiblesModuleDidLoad*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") +method searchCollectiblesModuleDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + method collectiblesModuleDidLoad*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/wallet_section/module.nim b/src/app/modules/main/wallet_section/module.nim index 4cf67fe38..2802bc2b4 100644 --- a/src/app/modules/main/wallet_section/module.nim +++ b/src/app/modules/main/wallet_section/module.nim @@ -7,6 +7,7 @@ import ../io_interface as delegate_interface import ./accounts/module as accounts_module import ./all_tokens/module as all_tokens_module import ./all_collectibles/module as all_collectibles_module +import ./collectibles_search/module as collectibles_search_module import ./assets/module as assets_module import ./saved_addresses/module as saved_addresses_module import ./buy_sell_crypto/module as buy_sell_crypto_module @@ -65,6 +66,7 @@ type accountsModule: accounts_module.AccessInterface allTokensModule: all_tokens_module.AccessInterface allCollectiblesModule: all_collectibles_module.AccessInterface + collectiblesSearchModule: collectibles_search_module.AccessInterface assetsModule: assets_module.AccessInterface sendModule: send_module.AccessInterface savedAddressesModule: saved_addresses_module.AccessInterface @@ -131,6 +133,7 @@ proc newModule*( result.allTokensModule = all_tokens_module.newModule(result, events, tokenService, walletAccountService, settingsService, communityTokensService) let allCollectiblesModule = all_collectibles_module.newModule(result, events, collectibleService, networkService, walletAccountService, settingsService) result.allCollectiblesModule = allCollectiblesModule + result.collectiblesSearchModule = collectibles_search_module.newModule(result, events, networkService) result.assetsModule = assets_module.newModule(result, events, walletAccountService, networkService, tokenService, currencyService) result.sendModule = send_module.newModule(result, events, walletAccountService, networkService, currencyService, @@ -176,6 +179,7 @@ method delete*(self: Module) = self.accountsModule.delete self.allTokensModule.delete self.allCollectiblesModule.delete + self.collectiblesSearchModule.delete self.assetsModule.delete self.savedAddressesModule.delete self.buySellCryptoModule.delete @@ -335,6 +339,7 @@ method load*(self: Module) = self.accountsModule.load() self.allTokensModule.load() self.allCollectiblesModule.load() + self.collectiblesSearchModule.load() self.assetsModule.load() self.savedAddressesModule.load() self.buySellCryptoModule.load() @@ -356,6 +361,9 @@ proc checkIfModuleDidLoad(self: Module) = if(not self.allCollectiblesModule.isLoaded()): return + if(not self.collectiblesSearchModule.isLoaded()): + return + if(not self.assetsModule.isLoaded()): return @@ -397,6 +405,9 @@ method allTokensModuleDidLoad*(self: Module) = method allCollectiblesModuleDidLoad*(self: Module) = self.checkIfModuleDidLoad() +method searchCollectiblesModuleDidLoad*(self: Module) = + self.checkIfModuleDidLoad() + method collectiblesModuleDidLoad*(self: Module) = self.checkIfModuleDidLoad() diff --git a/src/app/modules/shared/wallet_utils.nim b/src/app/modules/shared/wallet_utils.nim index e306e89ea..18b907c1a 100644 --- a/src/app/modules/shared/wallet_utils.nim +++ b/src/app/modules/shared/wallet_utils.nim @@ -5,6 +5,9 @@ import app_service/service/currency/dto as currency_dto import ../main/wallet_section/accounts/item as wallet_accounts_item import ../main/wallet_section/send/account_item as wallet_send_account_item +import backend/collectibles_types as collectibles +import app_service/common/types + proc currencyAmountToItem*(amount: float64, format: CurrencyFormatDto) : CurrencyAmount = return newCurrencyAmount( amount, @@ -72,3 +75,11 @@ proc walletAccountToWalletSendAccountItem*(w: WalletAccountDto, chainIds: seq[in w.testPreferredChainIds, canSend=w.walletType != "watch" and (w.operable==AccountFullyOperable or w.operable==AccountPartiallyOperable) ) + +proc contractTypeToTokenType*(contractType : ContractType): TokenType = + case contractType: + of ContractType.ContractTypeUnknown: return TokenType.Unknown + of ContractType.ContractTypeERC20: return TokenType.ERC20 + of ContractType.ContractTypeERC721: return TokenType.ERC721 + of ContractType.ContractTypeERC1155: return TokenType.ERC1155 + else: return TokenType.Unknown \ No newline at end of file diff --git a/src/app/modules/shared_models/collectibles_data_entry.nim b/src/app/modules/shared_models/collectibles_data_entry.nim new file mode 100644 index 000000000..c1e9c67db --- /dev/null +++ b/src/app/modules/shared_models/collectibles_data_entry.nim @@ -0,0 +1,216 @@ +import NimQml, json, stew/shims/strformat, sequtils, strutils, stint, strutils +import options + +import backend/collectibles as backend +import collectible_trait_model +import app_service/common/types +import app/modules/shared/wallet_utils + +# It is used to display a detailed collectibles entry in the QML UI +QtObject: + type + CollectiblesDataEntry* = ref object of QObject + id: backend.CollectibleUniqueID + data: backend.Collectible + traits: TraitModel + generatedId: string + generatedCollectionId: string + tokenType: TokenType + + proc setup(self: CollectiblesDataEntry) = + self.QObject.setup + + proc delete*(self: CollectiblesDataEntry) = + self.QObject.delete + + proc setData(self: CollectiblesDataEntry, data: backend.Collectible) = + self.data = data + self.traits = newTraitModel() + if isSome(data.collectibleData) and isSome(data.collectibleData.get().traits): + let traits = data.collectibleData.get().traits.get() + self.traits.setItems(traits) + self.setup() + + proc `$`*(self: CollectiblesDataEntry): string = + return fmt"""CollectiblesDataEntry( + id:{self.id}, + data:{self.data}, + traits:{self.traits}, + generatedId:{self.generatedId}, + generatedCollectionId:{self.generatedCollectionId}, + tokenType:{self.tokenType}, + )""" + + proc hasCollectibleData(self: CollectiblesDataEntry): bool = + return self.data != nil and isSome(self.data.collectibleData) + + proc getCollectibleData(self: CollectiblesDataEntry): backend.CollectibleData = + return self.data.collectibleData.get() + + proc getChainID*(self: CollectiblesDataEntry): int {.slot.} = + return self.id.contractID.chainID + + QtProperty[int] chainId: + read = getChainID + + proc getContractAddress*(self: CollectiblesDataEntry): string {.slot.} = + return self.id.contractID.address + + QtProperty[string] contractAddress: + read = getContractAddress + + proc getTokenID*(self: CollectiblesDataEntry): UInt256 = + return self.id.tokenID + + proc getTokenIDAsString*(self: CollectiblesDataEntry): string {.slot.} = + return self.getTokenID().toString() + + QtProperty[string] tokenId: + read = getTokenIDAsString + + # Unique ID to identify collectible, generated by us + proc getID*(self: CollectiblesDataEntry): backend.CollectibleUniqueID = + return self.id + + proc getIDAsString*(self: CollectiblesDataEntry): string = + return self.generatedId + + # Unique ID to identify collection, generated by us + proc getCollectionID*(self: CollectiblesDataEntry): backend.ContractID = + return self.id.contractID + + proc getCollectionIDAsString*(self: CollectiblesDataEntry): string = + return self.generatedCollectionId + + proc nameChanged*(self: CollectiblesDataEntry) {.signal.} + proc getName*(self: CollectiblesDataEntry): string {.slot.} = + if self.hasCollectibleData(): + result = self.data.collectibleData.get().name + if result == "": + result = "#" & self.getTokenIDAsString() + + QtProperty[string] name: + read = getName + notify = nameChanged + + proc imageURLChanged*(self: CollectiblesDataEntry) {.signal.} + proc getImageURL*(self: CollectiblesDataEntry): string {.slot.} = + if not self.hasCollectibleData() or isNone(self.getCollectibleData().imageUrl): + return "" + return self.getCollectibleData().imageUrl.get() + + QtProperty[string] imageUrl: + read = getImageURL + notify = imageURLChanged + + proc getOriginalMediaURL(self: CollectiblesDataEntry): string = + if not self.hasCollectibleData() or isNone(self.getCollectibleData().animationUrl): + return "" + return self.getCollectibleData().animationUrl.get() + + proc mediaURLChanged*(self: CollectiblesDataEntry) {.signal.} + proc getMediaURL*(self: CollectiblesDataEntry): string {.slot.} = + result = self.getOriginalMediaURL() + if result == "": + result = self.getImageURL() + + QtProperty[string] mediaUrl: + read = getMediaURL + notify = mediaURLChanged + + proc getOriginalMediaType(self: CollectiblesDataEntry): string = + if not self.hasCollectibleData() or isNone(self.getCollectibleData().animationMediaType): + return "" + return self.getCollectibleData().animationMediaType.get() + + proc mediaTypeChanged*(self: CollectiblesDataEntry) {.signal.} + proc getMediaType*(self: CollectiblesDataEntry): string {.slot.} = + result = self.getOriginalMediaType() + if result == "": + result = "image" + + QtProperty[string] mediaType: + read = getMediaType + notify = mediaTypeChanged + + proc backgroundColorChanged*(self: CollectiblesDataEntry) {.signal.} + proc getBackgroundColor*(self: CollectiblesDataEntry): string {.slot.} = + var color = "transparent" + if self.hasCollectibleData() and isSome(self.getCollectibleData().backgroundColor): + let backgroundColor = self.getCollectibleData().backgroundColor.get() + if backgroundColor != "": + color = "#" & backgroundColor + return color + + QtProperty[string] backgroundColor: + read = getBackgroundColor + notify = backgroundColorChanged + + proc descriptionChanged*(self: CollectiblesDataEntry) {.signal.} + proc getDescription*(self: CollectiblesDataEntry): string {.slot.} = + if not self.hasCollectibleData() or isNone(self.getCollectibleData().description): + return "" + return self.getCollectibleData().description.get() + + QtProperty[string] description: + read = getDescription + notify = descriptionChanged + + proc traitsChanged*(self: CollectiblesDataEntry) {.signal.} + proc getTraits*(self: CollectiblesDataEntry): QVariant {.slot.} = + return newQVariant(self.traits) + + QtProperty[QVariant] traits: + read = getTraits + notify = traitsChanged + + proc tokenTypeChanged*(self: CollectiblesDataEntry) {.signal.} + proc getTokenType*(self: CollectiblesDataEntry): int {.slot.} = + return self.tokenType.int + + QtProperty[int] tokenType: + read = getTokenType + notify = tokenTypeChanged + + proc updateDataIfSameID*(self: CollectiblesDataEntry, update: backend.Collectible): bool = + if self.id != update.id: + return false + + self.setData(update) + + # Notify changes for all properties + self.nameChanged() + self.imageUrlChanged() + self.mediaUrlChanged() + self.mediaTypeChanged() + self.backgroundColorChanged() + self.descriptionChanged() + self.traitsChanged() + return true + + proc newCollectiblesDataFullEntry*(data: backend.Collectible): CollectiblesDataEntry = + new(result, delete) + result.id = data.id + result.setData(data) + result.generatedId = result.id.toString() + result.generatedCollectionId = result.id.contractID.toString() + result.tokenType = contractTypeToTokenType(data.contractType.get()) + result.setup() + + proc newCollectiblesDataBasicEntry*(id: backend.CollectibleUniqueID): CollectiblesDataEntry = + new(result, delete) + result.id = id + result.traits = newTraitModel() + result.generatedId = result.id.toString() + result.generatedCollectionId = result.id.contractID.toString() + result.setup() + + proc newCollectiblesDataEmptyEntry*(): CollectiblesDataEntry = + let id = backend.CollectibleUniqueID( + contractID: backend.ContractID( + chainID: 0, + address: "" + ), + tokenID: stint.u256(0) + ) + return newCollectiblesDataBasicEntry(id) diff --git a/src/app/modules/shared_models/collectibles_data_model.nim b/src/app/modules/shared_models/collectibles_data_model.nim new file mode 100644 index 000000000..aae0a8245 --- /dev/null +++ b/src/app/modules/shared_models/collectibles_data_model.nim @@ -0,0 +1,240 @@ +import NimQml, Tables, strutils, stew/shims/strformat, sequtils, stint, json +import logging + +import ./collectibles_data_entry +import backend/collectibles as backend_collectibles + +type + CollectibleRole* {.pure.} = enum + Uid = UserRole + 1, + ChainId + ContractAddress + TokenId + Name + ImageUrl + MediaUrl + MediaType + BackgroundColor + CollectionUid + +QtObject: + type + Model* = ref object of QAbstractListModel + items: seq[CollectiblesDataEntry] + hasMore: bool + + proc delete(self: Model) = + self.items = @[] + self.QAbstractListModel.delete + + proc setup(self: Model) = + self.QAbstractListModel.setup + + proc newModel*(): Model = + new(result, delete) + result.setup + result.items = @[] + result.hasMore = true + + proc `$`*(self: Model): string = + for i in 0 ..< self.items.len: + result &= fmt"""[{i}]:({$self.items[i]})""" + + proc countChanged(self: Model) {.signal.} + proc getCount*(self: Model): int {.slot.} = + return self.items.len + + QtProperty[int] count: + read = getCount + notify = countChanged + + proc hasMoreChanged*(self: Model) {.signal.} + proc getHasMore*(self: Model): bool {.slot.} = + self.hasMore + QtProperty[bool] hasMore: + read = getHasMore + notify = hasMoreChanged + proc setHasMore(self: Model, hasMore: bool) = + if hasMore == self.hasMore: + return + self.hasMore = hasMore + self.hasMoreChanged() + + method canFetchMore*(self: Model, parent: QModelIndex): bool = + return self.hasMore + + proc loadMoreItems(self: Model) {.signal.} + + proc loadMore*(self: Model) {.slot.} = + self.loadMoreItems() + + method fetchMore*(self: Model, parent: QModelIndex) = + self.loadMore() + + method rowCount*(self: Model, index: QModelIndex = nil): int = + return self.getCount() + + method roleNames(self: Model): Table[int, string] = + { + CollectibleRole.Uid.int:"uid", + CollectibleRole.ChainId.int:"chainId", + CollectibleRole.ContractAddress.int:"contractAddress", + CollectibleRole.TokenId.int:"tokenId", + CollectibleRole.Name.int:"name", + CollectibleRole.MediaUrl.int:"mediaUrl", + CollectibleRole.MediaType.int:"mediaType", + CollectibleRole.ImageUrl.int:"imageUrl", + CollectibleRole.BackgroundColor.int:"backgroundColor", + CollectibleRole.CollectionUid.int:"collectionUid" + }.toTable + + method data(self: Model, index: QModelIndex, role: int): QVariant = + if (not index.isValid): + return + + if (index.row < 0 or index.row >= self.getCount()): + return + + let enumRole = role.CollectibleRole + + if index.row < self.items.len: + let item = self.items[index.row] + case enumRole: + of CollectibleRole.Uid: + result = newQVariant(item.getIDAsString()) + of CollectibleRole.ChainId: + result = newQVariant(item.getChainID()) + of CollectibleRole.ContractAddress: + result = newQVariant(item.getContractAddress()) + of CollectibleRole.TokenId: + result = newQVariant(item.getTokenIDAsString()) + of CollectibleRole.Name: + result = newQVariant(item.getName()) + of CollectibleRole.MediaUrl: + result = newQVariant(item.getMediaURL()) + of CollectibleRole.MediaType: + result = newQVariant(item.getMediaType()) + of CollectibleRole.ImageUrl: + result = newQVariant(item.getImageURL()) + of CollectibleRole.BackgroundColor: + result = newQVariant(item.getBackgroundColor()) + of CollectibleRole.CollectionUid: + result = newQVariant(item.getCollectionIDAsString()) + + proc resetCollectibleItems(self: Model, newItems: seq[CollectiblesDataEntry] = @[]) = + self.beginResetModel() + self.items = newItems + self.endResetModel() + self.countChanged() + + proc appendCollectibleItems(self: Model, newItems: seq[CollectiblesDataEntry]) = + if len(newItems) == 0: + return + + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + # Start after the current last real item + let startIdx = self.items.len + # End at the new last real item + let endIdx = startIdx + newItems.len - 1 + + self.beginInsertRows(parentModelIndex, startIdx, endIdx) + self.items.insert(newItems, startIdx) + self.endInsertRows() + self.countChanged() + + proc removeCollectibleItem(self: Model, idx: int) = + if idx < 0 or idx >= self.items.len: + return + + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + self.beginRemoveRows(parentModelIndex, idx, idx) + self.items.delete(idx) + self.endRemoveRows() + self.countChanged() + + proc updateCollectibleItems(self: Model, newItems: seq[CollectiblesDataEntry]) = + if len(self.items) == 0: + # Current list is empty, just replace with new list + self.resetCollectibleItems(newItems) + return + + if len(newItems) == 0: + # New list is empty, just remove all items + self.resetCollectibleItems() + return + + var newTable = initTable[string, int](len(newItems)) + for i in 0 ..< len(newItems): + newTable[newItems[i].getIDAsString()] = i + + # Needs to be built in sequential index order + var oldIndicesToRemove: seq[int] = @[] + for idx in 0 ..< len(self.items): + let uid = self.items[idx].getIDAsString() + if not newTable.hasKey(uid): + # Item in old list but not in new -> Must remove + oldIndicesToRemove.add(idx) + else: + # Item both in old and new lists -> Nothing to do in the current list, + # remove from the new list so it only holds new items. + newTable.del(uid) + + if len(oldIndicesToRemove) > 0: + var removedItems = 0 + for idx in oldIndicesToRemove: + let updatedIdx = idx - removedItems + self.removeCollectibleItem(updatedIdx) + removedItems += 1 + self.countChanged() + + var newItemsToAdd: seq[CollectiblesDataEntry] = @[] + for uid, idx in newTable: + newItemsToAdd.add(newItems[idx]) + self.appendCollectibleItems(newItemsToAdd) + + proc getItems*(self: Model): seq[CollectiblesDataEntry] = + return self.items + + proc getItemById*(self: Model, id: string): CollectiblesDataEntry = + for item in self.items: + if(cmpIgnoreCase(item.getIDAsString(), id) == 0): + return item + return nil + + proc setItems*(self: Model, newItems: seq[CollectiblesDataEntry], offset: int, hasMore: bool) = + if offset == 0: + self.resetCollectibleItems(newItems) + elif offset != self.getCount(): + error "invalid offset" + return + else: + self.appendCollectibleItems(newItems) + self.setHasMore(hasMore) + + # Checks the diff between the current list and the new list, appends new items, + # removes missing items. + # We assume the order of the items in the input could change, and we don't care + # about the order of the items in the model. + proc updateItems*(self: Model, newItems: seq[CollectiblesDataEntry]) = + self.updateCollectibleItems(newItems) + self.setHasMore(false) + + proc itemsDataUpdated(self: Model) {.signal.} + proc updateItemsData*(self: Model, updates: seq[backend_collectibles.Collectible]) = + var anyUpdated = false + for i in countdown(self.items.high, 0): + let entry = self.items[i] + for j in countdown(updates.high, 0): + let update = updates[j] + if entry.updateDataIfSameID(update): + let index = self.createIndex(i, 0, nil) + defer: index.delete + self.dataChanged(index, index) + anyUpdated = true + break + if anyUpdated: + self.itemsDataUpdated() diff --git a/src/app/modules/shared_models/collectibles_entry.nim b/src/app/modules/shared_models/collectibles_entry.nim index 0ff80109d..aa1d443cf 100644 --- a/src/app/modules/shared_models/collectibles_entry.nim +++ b/src/app/modules/shared_models/collectibles_entry.nim @@ -6,8 +6,7 @@ import collectible_trait_model import collectible_ownership_model import app_service/service/community_tokens/dto/community_token import app_service/common/types - -const invalidTimestamp* = high(int) +import app/modules/shared/wallet_utils # Additional data needed to build an Entry, which is # not included in the backend data and needs to be @@ -373,14 +372,6 @@ QtObject: self.communityImageChanged() return true - proc contractTypeToTokenType(contractType : ContractType): TokenType = - case contractType: - of ContractType.ContractTypeUnknown: return TokenType.Unknown - of ContractType.ContractTypeERC20: return TokenType.ERC20 - of ContractType.ContractTypeERC721: return TokenType.ERC721 - of ContractType.ContractTypeERC1155: return TokenType.ERC1155 - else: return TokenType.Unknown - proc newCollectibleDetailsFullEntry*(data: backend.Collectible, extradata: ExtraData): CollectiblesEntry = new(result, delete) result.id = data.id diff --git a/src/app/modules/shared_models/collections_data_entry.nim b/src/app/modules/shared_models/collections_data_entry.nim new file mode 100644 index 000000000..e76c9ba8c --- /dev/null +++ b/src/app/modules/shared_models/collections_data_entry.nim @@ -0,0 +1,140 @@ +import NimQml, json, stew/shims/strformat, sequtils, strutils, strutils +import options + +import backend/collectibles_types as backend +import app_service/common/types +import app/modules/shared/wallet_utils + +QtObject: + type + CollectionsDataEntry* = ref object of QObject + id: backend.ContractID + data: backend.Collection + generatedId: string + tokenType: TokenType + + proc setup(self: CollectionsDataEntry) = + self.QObject.setup + + proc delete*(self: CollectionsDataEntry) = + self.QObject.delete + + proc setData(self: CollectionsDataEntry, data: backend.Collection) = + self.data = data + self.setup() + + proc `$`*(self: CollectionsDataEntry): string = + return fmt"""CollectionsDataEntry( + id:{self.id}, + data:{self.data}, + generatedId:{self.generatedId}, + tokenType:{self.tokenType}, + )""" + + proc hasCollectionData(self: CollectionsDataEntry): bool = + return self.data != nil and isSome(self.data.collectionData) + + proc getCollectionData(self: CollectionsDataEntry): backend.CollectionData = + return self.data.collectionData.get() + + proc getChainID*(self: CollectionsDataEntry): int {.slot.} = + return self.id.chainID + + QtProperty[int] chainId: + read = getChainID + + proc getContractAddress*(self: CollectionsDataEntry): string {.slot.} = + return self.id.address + + QtProperty[string] contractAddress: + read = getContractAddress + + # Unique ID to identify collection, generated by us + proc getID*(self: CollectionsDataEntry): backend.ContractID = + return self.id + + proc getIDAsString*(self: CollectionsDataEntry): string = + return self.generatedId + + proc nameChanged*(self: CollectionsDataEntry) {.signal.} + proc getName*(self: CollectionsDataEntry): string {.slot.} = + if self.hasCollectionData(): + result = self.getCollectionData().name + if result == "": + result = self.getIDAsString() + + QtProperty[string] name: + read = getName + notify = nameChanged + + proc imageURLChanged*(self: CollectionsDataEntry) {.signal.} + proc getImageURL*(self: CollectionsDataEntry): string {.slot.} = + if not self.hasCollectionData(): + return "" + return self.getCollectionData().imageUrl + + QtProperty[string] imageUrl: + read = getImageURL + notify = imageURLChanged + + proc slugChanged*(self: CollectionsDataEntry) {.signal.} + proc getSlug*(self: CollectionsDataEntry): string {.slot.} = + if not self.hasCollectionData(): + return "" + return self.getCollectionData().slug + + QtProperty[string] collectionSlug: + read = getCollectionSlug + notify = collectionSlugChanged + + proc tokenTypeChanged*(self: CollectionsDataEntry) {.signal.} + proc getTokenType*(self: CollectionsDataEntry): int {.slot.} = + return self.tokenType.int + + QtProperty[int] tokenType: + read = getTokenType + notify = tokenTypeChanged + + proc communityIdChanged*(self: CollectionsDataEntry) {.signal.} + proc getCommunityId*(self: CollectionsDataEntry): string {.slot.} = + if not self.hasCollectionData(): + return "" + return self.data.communityId + + QtProperty[string] communityId: + read = getCommunityId + notify = communityIdChanged + + proc updateDataIfSameID*(self: CollectionsDataEntry, update: backend.Collection): bool = + if self.id != update.id: + return false + + self.setData(update) + + # Notify changes for all properties + self.nameChanged() + self.slugChanged() + self.imageUrlChanged() + return true + + proc newCollectionsDataFullEntry*(data: backend.Collection): CollectionsDataEntry = + new(result, delete) + result.id = data.id + result.setData(data) + result.generatedId = result.id.toString() + result.tokenType = contractTypeToTokenType(data.contractType) + result.setup() + + proc newCollectionsDataBasicEntry*(id: backend.ContractID): CollectionsDataEntry = + new(result, delete) + result.id = id + result.generatedId = result.id.toString() + result.setup() + + proc newCollectionsDataEmptyEntry*(): CollectionsDataEntry = + let id = backend.ContractID( + chainID: 0, + address: "" + ) + return newCollectionsDataBasicEntry(id) + diff --git a/src/app/modules/shared_models/collections_data_model.nim b/src/app/modules/shared_models/collections_data_model.nim new file mode 100644 index 000000000..f08275a3e --- /dev/null +++ b/src/app/modules/shared_models/collections_data_model.nim @@ -0,0 +1,230 @@ +import NimQml, Tables, strutils, stew/shims/strformat, sequtils, stint, json +import logging + +import ./collections_data_entry +import backend/collectibles_types as backend_collectibles + +type + CollectionRole* {.pure.} = enum + # ID roles + Uid = UserRole + 1, + ChainId + ContractAddress + TokenType + # Metadata roles + Name + Slug + ImageUrl + +QtObject: + type + Model* = ref object of QAbstractListModel + items: seq[CollectionsDataEntry] + hasMore: bool + + proc delete(self: Model) = + self.items = @[] + self.QAbstractListModel.delete + + proc setup(self: Model) = + self.QAbstractListModel.setup + + proc newModel*(): Model = + new(result, delete) + result.setup + result.items = @[] + result.hasMore = true + + proc `$`*(self: Model): string = + for i in 0 ..< self.items.len: + result &= fmt"""[{i}]:({$self.items[i]})""" + + proc countChanged(self: Model) {.signal.} + proc getCount*(self: Model): int {.slot.} = + return self.items.len + + QtProperty[int] count: + read = getCount + notify = countChanged + + proc hasMoreChanged*(self: Model) {.signal.} + proc getHasMore*(self: Model): bool {.slot.} = + self.hasMore + QtProperty[bool] hasMore: + read = getHasMore + notify = hasMoreChanged + proc setHasMore*(self: Model, hasMore: bool) = + if hasMore == self.hasMore: + return + self.hasMore = hasMore + self.hasMoreChanged() + + method canFetchMore*(self: Model, parent: QModelIndex): bool = + return self.hasMore + + proc loadMoreItems(self: Model) {.signal.} + + proc loadMore*(self: Model) {.slot.} = + self.loadMoreItems() + + method fetchMore*(self: Model, parent: QModelIndex) = + self.loadMore() + + method rowCount*(self: Model, index: QModelIndex = nil): int = + return self.getCount() + + method roleNames(self: Model): Table[int, string] = + { + CollectionRole.Uid.int:"uid", + CollectionRole.ChainId.int:"chainId", + CollectionRole.ContractAddress.int:"contractAddress", + CollectionRole.TokenType.int:"tokenType", + CollectionRole.Name.int:"name", + CollectionRole.Slug.int:"slug", + CollectionRole.ImageUrl.int:"imageUrl" + }.toTable + + method data(self: Model, index: QModelIndex, role: int): QVariant = + if (not index.isValid): + return + + if (index.row < 0 or index.row >= self.getCount()): + return + + let enumRole = role.CollectionRole + + if index.row < self.items.len: + let item = self.items[index.row] + case enumRole: + of CollectionRole.Uid: + result = newQVariant(item.getIDAsString()) + of CollectionRole.ChainId: + result = newQVariant(item.getChainID()) + of CollectionRole.ContractAddress: + result = newQVariant(item.getContractAddress()) + of CollectionRole.Name: + result = newQVariant(item.getName()) + of CollectionRole.ImageUrl: + result = newQVariant(item.getImageURL()) + of CollectionRole.Slug: + result = newQVariant(item.getSlug()) + of CollectionRole.TokenType: + result = newQVariant(item.getTokenType()) + + proc resetItems(self: Model, newItems: seq[CollectionsDataEntry] = @[]) = + self.beginResetModel() + self.items = newItems + self.endResetModel() + self.countChanged() + + proc appendItems(self: Model, newItems: seq[CollectionsDataEntry]) = + if len(newItems) == 0: + return + + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + # Start after the current last real item + let startIdx = self.items.len + # End at the new last real item + let endIdx = startIdx + newItems.len - 1 + + self.beginInsertRows(parentModelIndex, startIdx, endIdx) + self.items.insert(newItems, startIdx) + self.endInsertRows() + self.countChanged() + + proc removeItem(self: Model, idx: int) = + if idx < 0 or idx >= self.items.len: + return + + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + self.beginRemoveRows(parentModelIndex, idx, idx) + self.items.delete(idx) + self.endRemoveRows() + self.countChanged() + + proc updateCollectionItems(self: Model, newItems: seq[CollectionsDataEntry]) = + if len(self.items) == 0: + # Current list is empty, just replace with new list + self.resetItems(newItems) + return + + if len(newItems) == 0: + # New list is empty, just remove all items + self.resetItems() + return + + var newTable = initTable[string, int](len(newItems)) + for i in 0 ..< len(newItems): + newTable[newItems[i].getIDAsString()] = i + + # Needs to be built in sequential index order + var oldIndicesToRemove: seq[int] = @[] + for idx in 0 ..< len(self.items): + let uid = self.items[idx].getIDAsString() + if not newTable.hasKey(uid): + # Item in old list but not in new -> Must remove + oldIndicesToRemove.add(idx) + else: + # Item both in old and new lists -> Nothing to do in the current list, + # remove from the new list so it only holds new items. + newTable.del(uid) + + if len(oldIndicesToRemove) > 0: + var removedItems = 0 + for idx in oldIndicesToRemove: + let updatedIdx = idx - removedItems + self.removeItem(updatedIdx) + removedItems += 1 + self.countChanged() + + var newItemsToAdd: seq[CollectionsDataEntry] = @[] + for uid, idx in newTable: + newItemsToAdd.add(newItems[idx]) + self.appendItems(newItemsToAdd) + + proc getItems*(self: Model): seq[CollectionsDataEntry] = + return self.items + + proc getItemById*(self: Model, id: string): CollectionsDataEntry = + for item in self.items: + if(cmpIgnoreCase(item.getIDAsString(), id) == 0): + return item + return nil + + proc setItems*(self: Model, newItems: seq[CollectionsDataEntry], offset: int, hasMore: bool) = + if offset == 0: + self.resetItems(newItems) + elif offset != self.getCount(): + error "invalid offset" + return + else: + self.appendItems(newItems) + self.setHasMore(hasMore) + + # Checks the diff between the current list and the new list, appends new items, + # removes missing items. + # We assume the order of the items in the input could change, and we don't care + # about the order of the items in the model. + proc updateItems*(self: Model, newItems: seq[CollectionsDataEntry]) = + self.updateCollectionItems(newItems) + self.setHasMore(false) + + proc itemsDataUpdated(self: Model) {.signal.} + proc updateItemsData*(self: Model, updates: seq[backend_collectibles.Collection]) = + var anyUpdated = false + for i in countdown(self.items.high, 0): + let entry = self.items[i] + for j in countdown(updates.high, 0): + let update = updates[j] + if entry.updateDataIfSameID(update): + let index = self.createIndex(i, 0, nil) + defer: index.delete + self.dataChanged(index, index) + anyUpdated = true + break + if anyUpdated: + self.itemsDataUpdated() diff --git a/src/app/modules/shared_modules/collectibles_search/controller.nim b/src/app/modules/shared_modules/collectibles_search/controller.nim new file mode 100644 index 000000000..5abf7ad4e --- /dev/null +++ b/src/app/modules/shared_modules/collectibles_search/controller.nim @@ -0,0 +1,218 @@ +import NimQml, std/json, sequtils, sugar, strutils +import logging + +import app/modules/shared_models/collectibles_data_entry +import app/modules/shared_models/collectibles_data_model +import events_handler + +import app/core/eventemitter + +import backend/collectibles as backend_collectibles +import app_service/service/network/service as network_service + +const FETCH_BATCH_COUNT_DEFAULT = 50 + +QtObject: + type + Controller* = ref object of QObject + networkService: network_service.Service + + model: Model + fetchFromStart: bool + + eventsHandler: EventsHandler + + requestId: int32 + + chainId: int + contractAddress: string + text: string + + isFetching: bool + isError: bool + + previousCursor: string + nextCursor: string + provider: string + + dataType: backend_collectibles.CollectibleDataType + + proc setup(self: Controller) = + self.QObject.setup + + proc delete*(self: Controller) = + self.QObject.delete + + proc mustFetchFromStart(self: Controller): bool = + return self.previousCursor == "" + + proc hasMore(self: Controller): bool = + return self.mustFetchFromStart() or self.nextCursor != "" + + proc getModel*(self: Controller): Model = + return self.model + + proc getModelAsVariant*(self: Controller): QVariant {.slot.} = + return newQVariant(self.model) + + QtProperty[QVariant] model: + read = getModelAsVariant + + proc isFetchingChanged(self: Controller) {.signal.} + proc getIsFetching*(self: Controller): bool {.slot.} = + self.isFetching + QtProperty[bool] isFetching: + read = getIsFetching + notify = isFetchingChanged + proc setIsFetching*(self: Controller, value: bool) = + if value == self.isFetching: + return + self.isFetching = value + self.isFetchingChanged() + + proc isErrorChanged(self: Controller) {.signal.} + proc getIsError*(self: Controller): bool {.slot.} = + self.isError + QtProperty[bool] isError: + read = getIsError + notify = isErrorChanged + proc setIsError*(self: Controller, value: bool) = + if value == self.isError: + return + self.isError = value + self.isErrorChanged() + + proc loadMoreItems(self: Controller) = + if self.getIsFetching(): + return + + if not self.hasMore(): + return + + self.setIsFetching(true) + self.setIsError(false) + + let params = backend_collectibles.SearchCollectiblesParams( + chainID: self.chainId, + contractAddress: self.contractAddress, + text: self.text, + cursor: self.nextCursor, + limit: FETCH_BATCH_COUNT_DEFAULT, + providerID: self.provider + ) + let response = backend_collectibles.searchCollectiblesAsync(self.requestId, params, self.dataType) + if response.error != nil: + self.setIsFetching(false) + self.setIsError(true) + error "error searching collections: ", response.error + + proc resetModel(self: Controller) {.slot.} = + self.model.setItems(@[], 0, true) + + self.previousCursor = "" + self.nextCursor = "" + self.provider = "" + + self.loadMoreItems() + + proc onModelLoadMoreItems(self: Controller) {.slot.} = + self.loadMoreItems() + + proc textChanged(self: Controller) {.signal.} + proc getText*(self: Controller): string {.slot.} = + self.text + + QtProperty[string] text: + read = getText + notify = textChanged + + proc chainIdChanged(self: Controller) {.signal.} + proc getChainId*(self: Controller): int {.slot.} = + self.chainId + + QtProperty[int] chainId: + read = getChainId + notify = chainIdChanged + + proc contractAddressChanged(self: Controller) {.signal.} + proc getContractAddress*(self: Controller): string {.slot.} = + self.contractAddress + + QtProperty[string] contractAddress: + read = getContractAddress + notify = contractAddressChanged + + proc search*(self: Controller, chainId: int, contractAddress: string, text: string) {.slot.} = + if chainId == self.chainId and contractAddress == self.contractAddress and text == self.text: + return + + self.chainId = chainId + self.contractAddress = contractAddress + self.text = text + self.resetModel() + + proc processSearchCollectiblesResponse(self: Controller, response: JsonNode) = + let res = fromJson(response, backend_collectibles.SearchCollectiblesResponse) + + let isError = res.errorCode != backend_collectibles.ErrorCodeSuccess + + if isError: + error "error fetching collectibles entries: ", res.errorCode + self.setIsError(true) + self.setIsFetching(false) + return + + if self.nextCursor != res.previousCursor: + error "nextCursor mismatch" + self.setIsError(true) + self.setIsFetching(false) + return + + self.previousCursor = res.previousCursor + self.nextCursor = res.nextCursor + self.provider = res.provider + + let items = res.collectibles.map(header => (block: + newCollectiblesDataFullEntry(header) + )) + self.model.setItems(items, self.model.getCount(), self.hasMore()) + self.setIsFetching(false) + + proc setupEventHandlers(self: Controller) = + self.eventsHandler.onSearchCollectiblesDone(proc (jsonObj: JsonNode) = + self.processSearchCollectiblesResponse(jsonObj) + ) + + proc newController*( + requestId: int32, + networkService: network_service.Service, + events: EventEmitter, + dataType: backend_collectibles.CollectibleDataType = backend_collectibles.CollectibleDataType.Details, + ): Controller = + new(result, delete) + + result.requestId = requestId + result.dataType = dataType + + result.networkService = networkService + + result.model = newModel() + + result.isFetching = false + result.isError = false + + result.chainId = 0 + result.contractAddress = "" + result.text = "" + + result.previousCursor = "" + result.nextCursor = "" + result.provider = "" + + result.eventsHandler = newEventsHandler(result.requestId, events) + + result.setup() + + result.setupEventHandlers() + + signalConnect(result.model, "loadMoreItems()", result, "onModelLoadMoreItems()") diff --git a/src/app/modules/shared_modules/collectibles_search/events_handler.nim b/src/app/modules/shared_modules/collectibles_search/events_handler.nim new file mode 100644 index 000000000..b9b6b5855 --- /dev/null +++ b/src/app/modules/shared_modules/collectibles_search/events_handler.nim @@ -0,0 +1,54 @@ +import NimQml, std/json, sequtils, strutils, options +import tables + +import app/core/eventemitter +import app/core/signals/types + +import backend/collectibles as backend_collectibles + +type EventCallbackProc = proc (eventObject: JsonNode) + +# EventsHandler responsible for catching collectibles related backend events and reporting them +QtObject: + type + EventsHandler* = ref object of QObject + events: EventEmitter + eventHandlers: Table[string, EventCallbackProc] + + requestId: int32 + + proc setup(self: EventsHandler) = + self.QObject.setup + + proc delete*(self: EventsHandler) = + self.QObject.delete + + proc onSearchCollectiblesDone*(self: EventsHandler, handler: EventCallbackProc) = + self.eventHandlers[backend_collectibles.eventSearchCollectiblesDone] = handler + + proc handleApiEvents(self: EventsHandler, e: Args) = + var data = WalletSignal(e) + + if data.requestId.isSome and data.requestId.get() != self.requestId: + return + + if self.eventHandlers.hasKey(data.eventType): + let callback = self.eventHandlers[data.eventType] + let responseJson = parseJson(data.message) + callback(responseJson) + + proc newEventsHandler*(requestId: int32, events: EventEmitter): EventsHandler = + new(result, delete) + + result.requestId = requestId + + result.events = events + result.eventHandlers = initTable[string, EventCallbackProc]() + + result.setup() + + # Register for wallet events + let eventsHandler = result + result.events.on(SignalType.Wallet.event, proc(e: Args) = + eventsHandler.handleApiEvents(e) + ) diff --git a/src/app/modules/shared_modules/collections_search/controller.nim b/src/app/modules/shared_modules/collections_search/controller.nim new file mode 100644 index 000000000..8a9eb9a98 --- /dev/null +++ b/src/app/modules/shared_modules/collections_search/controller.nim @@ -0,0 +1,206 @@ +import NimQml, std/json, sequtils, sugar, strutils +import logging + +import app/modules/shared_models/collections_data_entry +import app/modules/shared_models/collections_data_model +import events_handler + +import app/core/eventemitter + +import backend/collectibles as backend_collectibles +import app_service/service/network/service as network_service + +const FETCH_BATCH_COUNT_DEFAULT = 50 + +QtObject: + type + Controller* = ref object of QObject + networkService: network_service.Service + + model: Model + fetchFromStart: bool + + eventsHandler: EventsHandler + + requestId: int32 + + chainId: int + text: string + + isFetching: bool + isError: bool + + previousCursor: string + nextCursor: string + provider: string + + dataType: backend_collectibles.CollectionDataType + + proc setup(self: Controller) = + self.QObject.setup + + proc delete*(self: Controller) = + self.QObject.delete + + proc mustFetchFromStart(self: Controller): bool = + return self.previousCursor == "" + + proc hasMore(self: Controller): bool = + return self.mustFetchFromStart() or self.nextCursor != "" + + proc getModel*(self: Controller): Model = + return self.model + + proc getModelAsVariant*(self: Controller): QVariant {.slot.} = + return newQVariant(self.model) + + QtProperty[QVariant] model: + read = getModelAsVariant + + proc isFetchingChanged(self: Controller) {.signal.} + proc getIsFetching*(self: Controller): bool {.slot.} = + self.isFetching + QtProperty[bool] isFetching: + read = getIsFetching + notify = isFetchingChanged + proc setIsFetching*(self: Controller, value: bool) = + if value == self.isFetching: + return + self.isFetching = value + self.isFetchingChanged() + + proc isErrorChanged(self: Controller) {.signal.} + proc getIsError*(self: Controller): bool {.slot.} = + self.isError + QtProperty[bool] isError: + read = getIsError + notify = isErrorChanged + proc setIsError*(self: Controller, value: bool) = + if value == self.isError: + return + self.isError = value + self.isErrorChanged() + + proc loadMoreItems(self: Controller) = + if self.getIsFetching(): + return + + if not self.hasMore(): + return + + self.setIsFetching(true) + self.setIsError(false) + + let params = backend_collectibles.SearchCollectionsParams( + chainID: self.chainId, + text: self.text, + cursor: self.nextCursor, + limit: FETCH_BATCH_COUNT_DEFAULT, + providerID: self.provider + ) + let response = backend_collectibles.searchCollectionsAsync(self.requestId, params, self.dataType) + if response.error != nil: + self.setIsFetching(false) + self.setIsError(true) + error "error searching collections: ", response.error + + proc resetModel(self: Controller) {.slot.} = + self.model.setItems(@[], 0, true) + + self.previousCursor = "" + self.nextCursor = "" + self.provider = "" + + self.loadMoreItems() + + proc textChanged(self: Controller) {.signal.} + proc getText*(self: Controller): string {.slot.} = + self.text + + QtProperty[string] text: + read = getText + notify = textChanged + + proc chainIdChanged(self: Controller) {.signal.} + proc getChainId*(self: Controller): int {.slot.} = + self.chainId + + QtProperty[int] chainId: + read = getChainId + notify = chainIdChanged + + proc search*(self: Controller, chainId: int, text: string) {.slot.} = + if chainId == self.chainId and text == self.text: + return + + self.chainId = chainId + self.text = text + self.resetModel() + + proc onModelLoadMoreItems(self: Controller) {.slot.} = + self.loadMoreItems() + + proc processSearchCollectionsResponse(self: Controller, response: JsonNode) = + let res = fromJson(response, backend_collectibles.SearchCollectionsResponse) + + let isError = res.errorCode != backend_collectibles.ErrorCodeSuccess + + if isError: + error "error fetching collections entries: ", res.errorCode + self.setIsError(true) + self.setIsFetching(false) + return + + if self.nextCursor != res.previousCursor: + error "nextCursor mismatch" + self.setIsError(true) + self.setIsFetching(false) + return + + self.previousCursor = res.previousCursor + self.nextCursor = res.nextCursor + self.provider = res.provider + + let items = res.collections.map(data => (block: + newCollectionsDataFullEntry(data) + )) + self.model.setItems(items, self.model.getCount(), self.hasMore()) + self.setIsFetching(false) + + proc setupEventHandlers(self: Controller) = + self.eventsHandler.onSearchCollectionsDone(proc (jsonObj: JsonNode) = + self.processSearchCollectionsResponse(jsonObj) + ) + + proc newController*( + requestId: int32, + networkService: network_service.Service, + events: EventEmitter, + dataType: backend_collectibles.CollectionDataType = backend_collectibles.CollectionDataType.Details, + ): Controller = + new(result, delete) + + result.requestId = requestId + result.dataType = dataType + + result.networkService = networkService + + result.model = newModel() + + result.isFetching = false + result.isError = false + + result.chainId = 0 + result.text = "" + + result.previousCursor = "" + result.nextCursor = "" + result.provider = "" + + result.eventsHandler = newEventsHandler(result.requestId, events) + + result.setup() + + result.setupEventHandlers() + + signalConnect(result.model, "loadMoreItems()", result, "onModelLoadMoreItems()") diff --git a/src/app/modules/shared_modules/collections_search/events_handler.nim b/src/app/modules/shared_modules/collections_search/events_handler.nim new file mode 100644 index 000000000..fae93a687 --- /dev/null +++ b/src/app/modules/shared_modules/collections_search/events_handler.nim @@ -0,0 +1,54 @@ +import NimQml, std/json, sequtils, strutils, options +import tables + +import app/core/eventemitter +import app/core/signals/types + +import backend/collectibles as backend_collectibles + +type EventCallbackProc = proc (eventObject: JsonNode) + +# EventsHandler responsible for catching collectibles related backend events and reporting them +QtObject: + type + EventsHandler* = ref object of QObject + events: EventEmitter + eventHandlers: Table[string, EventCallbackProc] + + requestId: int32 + + proc setup(self: EventsHandler) = + self.QObject.setup + + proc delete*(self: EventsHandler) = + self.QObject.delete + + proc onSearchCollectionsDone*(self: EventsHandler, handler: EventCallbackProc) = + self.eventHandlers[backend_collectibles.eventSearchCollectionsDone] = handler + + proc handleApiEvents(self: EventsHandler, e: Args) = + var data = WalletSignal(e) + + if data.requestId.isSome and data.requestId.get() != self.requestId: + return + + if self.eventHandlers.hasKey(data.eventType): + let callback = self.eventHandlers[data.eventType] + let responseJson = parseJson(data.message) + callback(responseJson) + + proc newEventsHandler*(requestId: int32, events: EventEmitter): EventsHandler = + new(result, delete) + + result.requestId = requestId + + result.events = events + result.eventHandlers = initTable[string, EventCallbackProc]() + + result.setup() + + # Register for wallet events + let eventsHandler = result + result.events.on(SignalType.Wallet.event, proc(e: Args) = + eventsHandler.handleApiEvents(e) + ) diff --git a/src/backend/collectibles.nim b/src/backend/collectibles.nim index 25cbe6ad9..965905d83 100644 --- a/src/backend/collectibles.nim +++ b/src/backend/collectibles.nim @@ -14,6 +14,7 @@ type ProfileShowcase WalletSend AllCollectibles + Search # Declared in services/wallet/collectibles/service.go const eventCollectiblesOwnershipUpdateStarted*: string = "wallet-collectibles-ownership-update-started" @@ -26,6 +27,8 @@ const eventCollectiblesDataUpdated*: string = "wallet-collectibles-data-updated" const eventOwnedCollectiblesFilteringDone*: string = "wallet-owned-collectibles-filtering-done" const eventGetCollectiblesDetailsDone*: string = "wallet-get-collectibles-details-done" const eventGetCollectionSocialsDone*: string ="wallet-get-collection-socials-done" +const eventSearchCollectiblesDone*: string ="wallet-search-collectibles-done" +const eventSearchCollectionsDone*: string ="wallet-search-collections-done" const invalidTimestamp*: int = -1 @@ -67,6 +70,22 @@ type collectibles*: seq[Collectible] errorCode*: ErrorCode + # Mirrors services/wallet/collectibles/service.go SearchCollectiblesResponse + SearchCollectiblesResponse* = object + collectibles*: seq[Collectible] + nextCursor*: string + previousCursor*: string + provider*: string + errorCode*: ErrorCode + + # Mirrors services/wallet/collectibles/service.go SearchCollectionsResponse + SearchCollectionsResponse* = object + collections*: seq[Collection] + nextCursor*: string + previousCursor*: string + provider*: string + errorCode*: ErrorCode + CommunityCollectiblesReceivedPayload* = object collectibles*: seq[Collectible] @@ -90,6 +109,23 @@ type FetchCriteria* = object fetchType*: FetchType maxCacheAgeSeconds*: int + + # see status-go/services/wallet/collectibles/manager.go SearchCollectionsParams + SearchCollectionsParams* = object + chainID*: int + text*: string + cursor*: string + limit*: int + providerID*: string + + # see status-go/services/wallet/collectibles/manager.go SearchCollectiblesParams + SearchCollectiblesParams* = object + chainID*: int + contractAddress*: string + text*: string + cursor*: string + limit*: int + providerID*: string # CollectibleOwnershipState proc `$`*(self: OwnershipStatus): string = @@ -176,6 +212,13 @@ proc `%`*(t: CollectibleDataType): JsonNode {.inline.} = proc `%`*(t: ref CollectibleDataType): JsonNode {.inline.} = return %(t[]) +# CollectionDataType +proc `%`*(t: CollectionDataType): JsonNode {.inline.} = + result = %(t.int) + +proc `%`*(t: ref CollectionDataType): JsonNode {.inline.} = + return %(t[]) + # FetchCriteria proc `$`*(self: FetchCriteria): string = return fmt"""FetchCriteria( @@ -191,6 +234,47 @@ proc `%`*(t: FetchCriteria): JsonNode {.inline.} = proc `%`*(t: ref FetchCriteria): JsonNode {.inline.} = return %(t[]) +#SearchCollectionsParams +proc `$`*(self: SearchCollectionsParams): string = + return fmt"""SearchCollectionsParams( + chainID:{self.chainID}, + text:{self.text}, + cursor:{self.cursor}, + limit:{self.limit}, + providerID:{self.providerID} + """ + +proc `%`*(t: SearchCollectionsParams): JsonNode {.inline.} = + result = newJObject() + result["chain_id"] = %t.chainID + result["text"] = %t.text + result["cursor"] = %t.cursor + result["limit"] = %t.limit + result["provider_id"] = %t.providerID + +proc `%`*(t: ref SearchCollectionsParams): JsonNode {.inline.} = + return %(t[]) + +#SearchCollectiblesParams +proc `$`*(self: SearchCollectiblesParams): string = + return fmt"""SearchCollectiblesParams( + chainID:{self.chainID}, + contractAddress:{self.contractAddress}, + text:{self.text}, + cursor:{self.cursor}, + limit:{self.limit}, + providerID:{self.providerID} + """ + +proc `%`*(t: SearchCollectiblesParams): JsonNode {.inline.} = + result = newJObject() + result["chain_id"] = %t.chainID + result["contract_address"] = %t.contractAddress + result["text"] = %t.text + result["cursor"] = %t.cursor + result["limit"] = %t.limit + result["provider_id"] = %t.providerID + # Responses proc fromJson*(e: JsonNode, T: typedesc[GetOwnedCollectiblesResponse]): GetOwnedCollectiblesResponse {.inline.} = var collectibles: seq[Collectible] @@ -228,6 +312,32 @@ proc fromJson*(e: JsonNode, T: typedesc[GetCollectiblesByUniqueIDResponse]): Get errorCode: ErrorCode(e["errorCode"].getInt()) ) +proc fromJson*(e: JsonNode, T: typedesc[SearchCollectiblesResponse]): SearchCollectiblesResponse {.inline.} = + var collectibles: seq[Collectible] = @[] + for item in e["collectibles"].getElems(): + collectibles.add(fromJson(item, Collectible)) + + result = T( + collectibles: collectibles, + nextCursor: e["nextCursor"].getStr(), + previousCursor: e["previousCursor"].getStr(), + provider: e["provider"].getStr(), + errorCode: ErrorCode(e["errorCode"].getInt()) + ) + +proc fromJson*(e: JsonNode, T: typedesc[SearchCollectionsResponse]): SearchCollectionsResponse {.inline.} = + var collections: seq[Collection] = @[] + for item in e["collections"].getElems(): + collections.add(fromJson(item, Collection)) + + result = T( + collections: collections, + nextCursor: e["nextCursor"].getStr(), + previousCursor: e["previousCursor"].getStr(), + provider: e["provider"].getStr(), + errorCode: ErrorCode(e["errorCode"].getInt()) + ) + proc fromJson*(e: JsonNode, T: typedesc[CommunityCollectiblesReceivedPayload]): CommunityCollectiblesReceivedPayload {.inline.} = var collectibles: seq[Collectible] = @[] for item in e.getElems(): @@ -278,6 +388,16 @@ rpc(fetchCollectionSocialsAsync, "wallet"): rpc(refetchOwnedCollectibles, "wallet"): discard +rpc(searchCollectionsAsync, "wallet"): + requestId: int32 + params: SearchCollectionsParams + dataType: CollectionDataType + +rpc(searchCollectiblesAsync, "wallet"): + requestId: int32 + params: SearchCollectiblesParams + dataType: CollectibleDataType + rpc(updateCollectiblePreferences, "accounts"): preferences: seq[CollectiblePreferences] diff --git a/src/backend/collectibles_types.nim b/src/backend/collectibles_types.nim index 593e105c2..75991164f 100644 --- a/src/backend/collectibles_types.nim +++ b/src/backend/collectibles_types.nim @@ -22,10 +22,14 @@ type contractID*: ContractID tokenID*: UInt256 - # see status-go/services/wallet/collectibles/service.go CollectibleDataType + # see status-go/services/wallet/collectibles/types.go CollectibleDataType CollectibleDataType* {.pure.} = enum UniqueID, Header, Details, CommunityHeader + # see status-go/services/wallet/collectibles/types.go CollectionDataType + CollectionDataType* {.pure.} = enum + ContractID, Details + # Mirrors services/wallet/thirdparty/collectible_types.go CollectibleTrait CollectibleTrait* = ref object of RootObj trait_type*: string @@ -73,6 +77,13 @@ type latestTxHash*: Option[string] receivedAmount*: Option[float64] contractType*: Option[ContractType] + + Collection* = ref object of RootObj + dataType*: CollectionDataType + id* : ContractID + communityId*: string + contractType*: ContractType + collectionData*: Option[CollectionData] CollectionSocials* = ref object of RootObj website*: string @@ -409,6 +420,33 @@ proc toIds(self: seq[Collectible]): seq[CollectibleUniqueID] = for c in self: result.add(c.id) +# Collection +proc `$`*(self: Collection): string = + return fmt"""Collection( + dataType:{self.dataType}, + id:{self.id}, + contractType:{self.contractType}, + collectionData:{self.collectionData}, + communityId:{self.communityId} + )""" + +proc fromJson*(t: JsonNode, T: typedesc[Collection]): Collection {.inline.} = + result = Collection() + result.dataType = t["data_type"].getInt().CollectionDataType + result.id = fromJson(t["id"], ContractID) + let collectionDataNode = t{"collection_data"} + if collectionDataNode != nil and collectionDataNode.kind != JNull: + result.collectionData = some(fromJson(collectionDataNode, CollectionData)) + else: + result.collectionData = none(CollectionData) + result.communityId = t["community_id"].getStr + result.contractType = ContractType(t["contract_type"].getInt()) + +proc toIds(self: seq[Collection]): seq[ContractID] = + result = @[] + for c in self: + result.add(c.id) + # CollectibleBalance proc `$`*(self: CollectibleBalance): string = return fmt"""CollectibleBalance( diff --git a/ui/app/AppLayouts/Wallet/stores/CollectiblesStore.qml b/ui/app/AppLayouts/Wallet/stores/CollectiblesStore.qml index ca98cd584..35b9dc550 100644 --- a/ui/app/AppLayouts/Wallet/stores/CollectiblesStore.qml +++ b/ui/app/AppLayouts/Wallet/stores/CollectiblesStore.qml @@ -12,6 +12,7 @@ QtObject { /* PRIVATE: Modules used to get data from backend */ readonly property var _allCollectiblesModule: !!walletSectionAllCollectibles ? walletSectionAllCollectibles : null + readonly property var _collectiblesSearchModule : !!walletSectionCollectiblesSearch ? walletSectionCollectiblesSearch : null /* This list contains the complete list of collectibles with separate entry per collectible which has a unique [network + contractAddress + tokenID] */ @@ -103,4 +104,26 @@ QtObject { function getDetailedCollectible(chainId, contractAddress, tokenId) { walletSection.collectibleDetailsController.getDetailedCollectible(chainId, contractAddress, tokenId) } + + /* The following are used to search for collections */ + readonly property var _collectionsSearchController: !!root._collectiblesSearchModule ? root._collectiblesSearchModule.collectionsSearchController : null + function searchCollections(chainId, text) { + root._collectionsSearchController.search(chainId, text) + } + + /* The following are used to display the collection search results */ + readonly property var collectionsSearchResults: !!root._collectionsSearchController ? root._collectionsSearchController.model : null + readonly property bool areCollectionsSearchResultsFetching: !!root._collectionsSearchController ? root._collectionsSearchController.isFetching : true + readonly property bool areCollectionsSearchResultsError: !!root._collectionsSearchController ? root._collectionsSearchController.isError : false + + /* The following are used to search for collectibles */ + readonly property var _collectiblesSearchController: !!root._collectiblesSearchModule ? root._collectiblesSearchModule.collectiblesSearchController : null + function searchCollectibles(chainId, contractAddress, text) { + root._collectiblesSearchController.search(chainId, contractAddress, text) + } + + /* The following are used to display the collection search results */ + readonly property var collectiblesSearchResults: !!root._collectiblesSearchController ? root._collectiblesSearchController.model : null + readonly property bool areCollectiblesSearchResultsFetching: !!root._collectiblesSearchController ? root._collectiblesSearchController.isFetching : true + readonly property bool areCollectiblesSearchResultsError: !!root._collectiblesSearchController ? root._collectiblesSearchController.isError : false } diff --git a/vendor/status-go b/vendor/status-go index 10cf28620..7d7af6249 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit 10cf2862086630625434f8b2239227dee7440858 +Subproject commit 7d7af6249176ea32bd458c67bca4010444df6389