diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index 64bfb29ddf2..3157be83261 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -26,6 +26,7 @@ import app_service/service/node_configuration/service as node_configuration_serv import app_service/service/network/service as network_service import app_service/service/activity_center/service as activity_center_service import app_service/service/saved_address/service as saved_address_service +import app_service/service/following_address/service as following_address_service import app_service/service/devices/service as devices_service import app_service/service/mailservers/service as mailservers_service import app_service/service/gif/service as gif_service @@ -89,6 +90,7 @@ type privacyService: privacy_service.Service nodeConfigurationService: node_configuration_service.Service savedAddressService: saved_address_service.Service + followingAddressService: following_address_service.Service devicesService: devices_service.Service mailserversService: mailservers_service.Service nodeService: node_service.Service @@ -211,6 +213,8 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController = result.accountsService) result.savedAddressService = saved_address_service.newService(statusFoundation.threadpool, statusFoundation.events, result.networkService, result.settingsService) + result.followingAddressService = following_address_service.newService(statusFoundation.threadpool, statusFoundation.events, + result.networkService) result.devicesService = devices_service.newService(statusFoundation.events, statusFoundation.threadpool, result.settingsService, result.accountsService, result.walletAccountService) result.mailserversService = mailservers_service.newService(statusFoundation.events, statusFoundation.threadpool, @@ -262,6 +266,7 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController = result.stickersService, result.activityCenterService, result.savedAddressService, + result.followingAddressService, result.nodeConfigurationService, result.devicesService, result.mailserversService, @@ -386,6 +391,7 @@ proc load(self: AppController) = self.stickersService.init() self.activityCenterService.init() self.savedAddressService.init() + self.followingAddressService.init() self.aboutService.init() self.ensService.init() self.tokensService.init() diff --git a/src/app/modules/main/module.nim b/src/app/modules/main/module.nim index 4bbd353574b..05195ba8153 100644 --- a/src/app/modules/main/module.nim +++ b/src/app/modules/main/module.nim @@ -52,6 +52,7 @@ import app_service/service/privacy/service as privacy_service import app_service/service/stickers/service as stickers_service import app_service/service/activity_center/service as activity_center_service import app_service/service/saved_address/service as saved_address_service +import app_service/service/following_address/service as following_address_service import app_service/service/node/service as node_service import app_service/service/node_configuration/service as node_configuration_service import app_service/service/devices/service as devices_service @@ -100,6 +101,7 @@ type accountsService: accounts_service.Service walletAccountService: wallet_account_service.Service savedAddressService: saved_address_service.Service + followingAddressService: following_address_service.Service networkConnectionService: network_connection_service.Service stickersService: stickers_service.Service communityTokensService: community_tokens_service.Service @@ -159,6 +161,7 @@ proc newModule*[T]( stickersService: stickers_service.Service, activityCenterService: activity_center_service.Service, savedAddressService: saved_address_service.Service, + followingAddressService: following_address_service.Service, nodeConfigurationService: node_configuration_service.Service, devicesService: devices_service.Service, mailserversService: mailservers_service.Service, @@ -212,6 +215,7 @@ proc newModule*[T]( result.accountsService = accountsService result.walletAccountService = walletAccountService result.savedAddressService = savedAddressService + result.followingAddressService = followingAddressService result.stickersService = stickersService result.communityTokensService = communityTokensService @@ -220,7 +224,7 @@ proc newModule*[T]( result.walletSectionModule = wallet_section_module.newModule( result, events, tokenService, collectibleService, currencyService, rampService, transactionService, walletAccountService, - settingsService, savedAddressService, networkService, accountsService, + settingsService, savedAddressService, followingAddressService, networkService, accountsService, keycardService, nodeService, networkConnectionService, devicesService, communityTokensService, threadpool ) diff --git a/src/app/modules/main/wallet_section/following_addresses/controller.nim b/src/app/modules/main/wallet_section/following_addresses/controller.nim new file mode 100644 index 00000000000..c94db5536cd --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/controller.nim @@ -0,0 +1,39 @@ +import io_interface, chronicles +import app/core/eventemitter +import app_service/service/following_address/service as following_address_service + +logScope: + topics = "following-addresses-controller" + +type + Controller* = ref object of RootObj + delegate: io_interface.AccessInterface + events: EventEmitter + followingAddressService: following_address_service.Service + +proc newController*( + delegate: io_interface.AccessInterface, + events: EventEmitter, + followingAddressService: following_address_service.Service +): Controller = + result = Controller() + result.delegate = delegate + result.events = events + result.followingAddressService = followingAddressService + +proc delete*(self: Controller) = + discard + +proc init*(self: Controller) = + self.events.on(following_address_service.SIGNAL_FOLLOWING_ADDRESSES_UPDATED) do(e:Args): + let args = following_address_service.FollowingAddressesArgs(e) + self.delegate.loadFollowingAddresses(args.userAddress) + +proc getFollowingAddresses*(self: Controller, userAddress: string): seq[following_address_service.FollowingAddressDto] = + return self.followingAddressService.getFollowingAddresses(userAddress) + +proc fetchFollowingAddresses*(self: Controller, userAddress: string, search: string = "", limit: int = 10, offset: int = 0) = + self.followingAddressService.fetchFollowingAddresses(userAddress, search, limit, offset) + +proc getTotalFollowingCount*(self: Controller): int = + return self.followingAddressService.getTotalFollowingCount() diff --git a/src/app/modules/main/wallet_section/following_addresses/io_interface.nim b/src/app/modules/main/wallet_section/following_addresses/io_interface.nim new file mode 100644 index 00000000000..905bbb00dc6 --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/io_interface.nim @@ -0,0 +1,29 @@ +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 viewDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method loadFollowingAddresses*(self: AccessInterface, userAddress: string) {.base.} = + raise newException(ValueError, "No implementation available") + +method fetchFollowingAddresses*(self: AccessInterface, userAddress: string, search: string = "", limit: int = 10, offset: int = 0) {.base.} = + raise newException(ValueError, "No implementation available") + +method getTotalFollowingCount*(self: AccessInterface): int {.base.} = + raise newException(ValueError, "No implementation available") + +type + ## Abstract class (concept) which must be implemented by object/s used in this + ## module. + DelegateInterface* = concept c diff --git a/src/app/modules/main/wallet_section/following_addresses/item.nim b/src/app/modules/main/wallet_section/following_addresses/item.nim new file mode 100644 index 00000000000..b60b71aae3e --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/item.nim @@ -0,0 +1,48 @@ +import stew/shims/strformat + +type + Item* = object + address: string + ensName: string + tags: seq[string] + avatar: string + +proc initItem*( + address: string, + ensName: string, + tags: seq[string], + avatar: string +): Item = + result.address = address + result.ensName = ensName + result.tags = tags + result.avatar = avatar + +proc `$`*(self: Item): string = + result = fmt"""FollowingAddressItem( + address: {self.address}, + ensName: {self.ensName}, + tags: {self.tags}, + avatar: {self.avatar}, + ]""" + +proc isEmpty*(self: Item): bool = + return self.address.len == 0 + +proc getAddress*(self: Item): string = + return self.address + +proc getEnsName*(self: Item): string = + return self.ensName + +proc getTags*(self: Item): seq[string] = + return self.tags + +proc getName*(self: Item): string = + # Use ENS name if available, otherwise use address + if self.ensName.len > 0: + return self.ensName + return self.address + +proc getAvatar*(self: Item): string = + return self.avatar diff --git a/src/app/modules/main/wallet_section/following_addresses/model.nim b/src/app/modules/main/wallet_section/following_addresses/model.nim new file mode 100644 index 00000000000..8ee14b81e0a --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/model.nim @@ -0,0 +1,93 @@ +import nimqml, tables, strutils, stew/shims/strformat, chronicles + +import item + +export item + +logScope: + topics = "following-addresses-model" + +type + ModelRole {.pure.} = enum + Address = UserRole + 1, + EnsName, + Tags, + Name, + Avatar + +QtObject: + type + Model* = ref object of QAbstractListModel + items: seq[Item] + + proc setup(self: Model) + proc delete(self: Model) + + proc newModel*(): Model = + new(result, delete) + result.setup + + 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.} = + self.items.len + + QtProperty[int] count: + read = getCount + notify = countChanged + + method rowCount(self: Model, index: QModelIndex = nil): int = + return self.items.len + + method roleNames(self: Model): Table[int, string] = + { + ModelRole.Address.int:"address", + ModelRole.EnsName.int:"ensName", + ModelRole.Tags.int:"tags", + ModelRole.Name.int:"name", + ModelRole.Avatar.int:"avatar", + }.toTable + + method data(self: Model, index: QModelIndex, role: int): QVariant = + if (not index.isValid): + return + + if (index.row < 0 or index.row >= self.items.len): + return + + let item = self.items[index.row] + let enumRole = role.ModelRole + + case enumRole: + of ModelRole.Address: + result = newQVariant(item.getAddress()) + of ModelRole.EnsName: + result = newQVariant(item.getEnsName()) + of ModelRole.Tags: + result = newQVariant(item.getTags().join(",")) + of ModelRole.Name: + result = newQVariant(item.getName()) + of ModelRole.Avatar: + result = newQVariant(item.getAvatar()) + + proc setItems*(self: Model, items: seq[Item]) = + self.beginResetModel() + self.items = items + self.endResetModel() + + proc getItemByAddress*(self: Model, address: string): Item = + if address.len == 0: + return + for item in self.items: + if cmpIgnoreCase(item.getAddress(), address) == 0: + return item + + proc setup(self: Model) = + self.QAbstractListModel.setup + + proc delete(self: Model) = + self.QAbstractListModel.delete diff --git a/src/app/modules/main/wallet_section/following_addresses/module.nim b/src/app/modules/main/wallet_section/following_addresses/module.nim new file mode 100644 index 00000000000..2e5e58ca7e8 --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/module.nim @@ -0,0 +1,71 @@ +import nimqml, sugar, sequtils, chronicles +import ../io_interface as delegate_interface + +import app/global/global_singleton +import app/core/eventemitter +import app_service/service/following_address/service as following_address_service + +import io_interface, view, controller, model, item + +export io_interface + +logScope: + topics = "following-addresses-module" + +type + Module* = ref object of io_interface.AccessInterface + delegate: delegate_interface.AccessInterface + view: View + viewVariant: QVariant + moduleLoaded: bool + controller: Controller + +proc newModule*( + delegate: delegate_interface.AccessInterface, + events: EventEmitter, + followingAddressService: following_address_service.Service, +): Module = + result = Module() + result.delegate = delegate + result.view = newView(result) + result.viewVariant = newQVariant(result.view) + result.controller = newController(result, events, followingAddressService) + result.moduleLoaded = false + +method delete*(self: Module) = + self.viewVariant.delete + self.view.delete + +method loadFollowingAddresses*(self: Module, userAddress: string) = + let followingAddresses = self.controller.getFollowingAddresses(userAddress) + self.view.setItems( + followingAddresses.map(f => initItem( + f.address, + f.ensName, + f.tags, + f.avatar, + )) + ) + self.view.totalFollowingCountChanged() + self.view.followingAddressesUpdated(userAddress) + +method load*(self: Module) = + try: + singletonInstance.engine.setRootContextProperty("walletSectionFollowingAddresses", self.viewVariant) + self.controller.init() + self.view.load() + except Exception as e: + error "following_addresses load() failed", msg=e.msg + +method isLoaded*(self: Module): bool = + return self.moduleLoaded + +method viewDidLoad*(self: Module) = + self.moduleLoaded = true + self.delegate.followingAddressesModuleDidLoad() + +method fetchFollowingAddresses*(self: Module, userAddress: string, search: string = "", limit: int = 10, offset: int = 0) = + self.controller.fetchFollowingAddresses(userAddress, search, limit, offset) + +method getTotalFollowingCount*(self: Module): int = + return self.controller.getTotalFollowingCount() diff --git a/src/app/modules/main/wallet_section/following_addresses/view.nim b/src/app/modules/main/wallet_section/following_addresses/view.nim new file mode 100644 index 00000000000..3881a8f8656 --- /dev/null +++ b/src/app/modules/main/wallet_section/following_addresses/view.nim @@ -0,0 +1,55 @@ +import nimqml + +import model +import io_interface + +QtObject: + type + View* = ref object of QObject + delegate: io_interface.AccessInterface + model: Model + modelVariant: QVariant + + proc delete*(self: View) + + proc newView*(delegate: io_interface.AccessInterface): View = + new(result, delete) + result.QObject.setup + result.delegate = delegate + result.model = newModel() + result.modelVariant = newQVariant(result.model) + + proc load*(self: View) = + self.delegate.viewDidLoad() + + proc modelChanged*(self: View) {.signal.} + + proc getModel*(self: View): Model = + return self.model + + proc getModelVariant(self: View): QVariant {.slot.} = + return self.modelVariant + + QtProperty[QVariant] model: + read = getModelVariant + notify = modelChanged + + proc setItems*(self: View, items: seq[Item]) = + self.model.setItems(items) + + proc followingAddressesUpdated*(self: View, userAddress: string) {.signal.} + + proc totalFollowingCountChanged*(self: View) {.signal.} + + proc getTotalFollowingCount*(self: View): int {.slot.} = + return self.delegate.getTotalFollowingCount() + + QtProperty[int] totalFollowingCount: + read = getTotalFollowingCount + notify = totalFollowingCountChanged + + proc fetchFollowingAddresses*(self: View, userAddress: string, search: string = "", limit: int = 10, offset: int = 0) {.slot.} = + self.delegate.fetchFollowingAddresses(userAddress, search, limit, offset) + + proc delete*(self: View) = + self.QObject.delete diff --git a/src/app/modules/main/wallet_section/io_interface.nim b/src/app/modules/main/wallet_section/io_interface.nim index 5b6b40af59b..3543a1edf0f 100644 --- a/src/app/modules/main/wallet_section/io_interface.nim +++ b/src/app/modules/main/wallet_section/io_interface.nim @@ -64,6 +64,9 @@ method networksModuleDidLoad*(self: AccessInterface) {.base.} = method savedAddressesModuleDidLoad*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") +method followingAddressesModuleDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + method buySellCryptoModuleDidLoad*(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 607f589d0fb..942e8cea775 100644 --- a/src/app/modules/main/wallet_section/module.nim +++ b/src/app/modules/main/wallet_section/module.nim @@ -9,6 +9,7 @@ import ./all_tokens/module as all_tokens_module import ./all_collectibles/module as all_collectibles_module import ./assets/module as assets_module import ./saved_addresses/module as saved_addresses_module +import ./following_addresses/module as following_addresses_module import ./buy_sell_crypto/module as buy_sell_crypto_module import ./networks/module as networks_module import ./overview/module as overview_module @@ -34,6 +35,7 @@ import app_service/service/transaction/service as transaction_service import app_service/service/wallet_account/service as wallet_account_service import app_service/service/settings/service as settings_service import app_service/service/saved_address/service as saved_address_service +import app_service/service/following_address/service as following_address_service import app_service/service/network/service as network_service import app_service/service/accounts/service as accounts_service import app_service/service/node/service as node_service @@ -74,6 +76,7 @@ type # TODO: replace this with sendModule when old one is removed newSendModule: new_send_module.AccessInterface savedAddressesModule: saved_addresses_module.AccessInterface + followingAddressesModule: following_addresses_module.AccessInterface buySellCryptoModule: buy_sell_crypto_module.AccessInterface overviewModule: overview_module.AccessInterface networksModule: networks_module.AccessInterface @@ -84,6 +87,7 @@ type accountsService: accounts_service.Service walletAccountService: wallet_account_service.Service savedAddressService: saved_address_service.Service + followingAddressService: following_address_service.Service devicesService: devices_service.Service walletConnectService: wc_service.Service walletConnectController: wc_controller.Controller @@ -115,6 +119,7 @@ proc newModule*( walletAccountService: wallet_account_service.Service, settingsService: settings_service.Service, savedAddressService: saved_address_service.Service, + followingAddressService: following_address_service.Service, networkService: network_service.Service, accountsService: accounts_service.Service, keycardService: keycard_service.Service, @@ -131,6 +136,7 @@ proc newModule*( result.accountsService = accountsService result.walletAccountService = walletAccountService result.savedAddressService = savedAddressService + result.followingAddressService = followingAddressService result.devicesService = devicesService result.moduleLoaded = false result.controller = newController(result, settingsService, walletAccountService, currencyService, networkService) @@ -146,6 +152,7 @@ proc newModule*( transactionService, keycardService) result.newSendModule = newSendModule.newModule(result, events, walletAccountService, networkService, transactionService, keycardService) result.savedAddressesModule = saved_addresses_module.newModule(result, events, savedAddressService) + result.followingAddressesModule = following_addresses_module.newModule(result, events, followingAddressService) result.buySellCryptoModule = buy_sell_crypto_module.newModule(result, events, rampService) result.overviewModule = overview_module.newModule(result, events, walletAccountService, currencyService) result.networksModule = networks_module.newModule(result, events, networkService, walletAccountService, settingsService) @@ -190,6 +197,7 @@ method delete*(self: Module) = self.allCollectiblesModule.delete self.assetsModule.delete self.savedAddressesModule.delete + self.followingAddressesModule.delete self.buySellCryptoModule.delete self.sendModule.delete self.newSendModule.delete @@ -356,6 +364,7 @@ method load*(self: Module) = self.allCollectiblesModule.load() self.assetsModule.load() self.savedAddressesModule.load() + self.followingAddressesModule.load() self.buySellCryptoModule.load() self.overviewModule.load() self.sendModule.load() @@ -384,6 +393,9 @@ proc checkIfModuleDidLoad(self: Module) = if(not self.savedAddressesModule.isLoaded()): return + if(not self.followingAddressesModule.isLoaded()): + return + if(not self.buySellCryptoModule.isLoaded()): return @@ -432,6 +444,9 @@ method transactionsModuleDidLoad*(self: Module) = method savedAddressesModuleDidLoad*(self: Module) = self.checkIfModuleDidLoad() +method followingAddressesModuleDidLoad*(self: Module) = + self.checkIfModuleDidLoad() + method buySellCryptoModuleDidLoad*(self: Module) = self.checkIfModuleDidLoad() diff --git a/src/app_service/service/following_address/async_tasks.nim b/src/app_service/service/following_address/async_tasks.nim new file mode 100644 index 00000000000..8fe2e488d56 --- /dev/null +++ b/src/app_service/service/following_address/async_tasks.nim @@ -0,0 +1,31 @@ +include app_service/common/json_utils +include app/core/tasks/common + +import backend/following_addresses +import stew/shims/strformat +import chronicles + +logScope: + topics = "following-address-async-tasks" + +type + FetchFollowingAddressesTaskArg = ref object of QObjectTaskArg + userAddress: string + search: string + limit: int + offset: int + +proc fetchFollowingAddressesTask*(argEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[FetchFollowingAddressesTaskArg](argEncoded) + var output = %*{ + "followingAddresses": "", + "userAddress": arg.userAddress, + "search": arg.search, + "error": "" + } + try: + let response = following_addresses.getFollowingAddresses(arg.userAddress, arg.search, arg.limit, arg.offset) + output["followingAddresses"] = %*response + except Exception as e: + output["error"] = %* fmt"Error fetching following addresses: {e.msg}" + arg.finish(output) diff --git a/src/app_service/service/following_address/dto.nim b/src/app_service/service/following_address/dto.nim new file mode 100644 index 00000000000..f907088ca87 --- /dev/null +++ b/src/app_service/service/following_address/dto.nim @@ -0,0 +1,43 @@ +import json, strutils + +include ../../common/json_utils + +type + FollowingAddressDto* = ref object of RootObj + address*: string + tags*: seq[string] + ensName*: string # From EFP API + avatar*: string # Avatar URL from EFP API + records*: JsonNode # Social links and other ENS records from EFP API + +proc toFollowingAddressDto*(jsonObj: JsonNode): FollowingAddressDto = + result = FollowingAddressDto() + discard jsonObj.getProp("address", result.address) + + # Handle tags array manually since getProp doesn't support seq[string] + if jsonObj.hasKey("tags") and jsonObj["tags"].kind == JArray: + result.tags = @[] + for tag in jsonObj["tags"]: + if tag.kind == JString: + result.tags.add(tag.getStr()) + else: + result.tags = @[] + + # Get ENS data from JSON (provided by EFP API) + discard jsonObj.getProp("ensName", result.ensName) + discard jsonObj.getProp("avatar", result.avatar) + + # Get records object if present + if jsonObj.hasKey("records"): + result.records = jsonObj["records"] + else: + result.records = newJObject() + +proc toJsonNode*(self: FollowingAddressDto): JsonNode = + result = %* { + "address": self.address, + "tags": self.tags, + "ensName": self.ensName, + "avatar": self.avatar, + "records": self.records + } diff --git a/src/app_service/service/following_address/service.nim b/src/app_service/service/following_address/service.nim new file mode 100644 index 00000000000..db2895edc5a --- /dev/null +++ b/src/app_service/service/following_address/service.nim @@ -0,0 +1,130 @@ +import nimqml, chronicles, strutils, sequtils, json, tables + +import dto + +import backend/following_addresses as backend +import app/core/eventemitter +import app/core/signals/types +import app/core/[main] +import app/core/tasks/[qt, threadpool] +import app_service/service/network/service as network_service + +export dto + +include async_tasks + +logScope: + topics = "following-address-service" + +# Signals which may be emitted by this service: +const SIGNAL_FOLLOWING_ADDRESSES_UPDATED* = "followingAddressesUpdated" + +type + FollowingAddressesArgs* = ref object of Args + userAddress*: string + addresses*: seq[FollowingAddressDto] + +QtObject: + type Service* = ref object of QObject + threadpool: ThreadPool + events: EventEmitter + followingAddressesTable: Table[string, seq[FollowingAddressDto]] + networkService: network_service.Service + totalFollowingCount: int + + proc delete*(self: Service) + proc newService*(threadpool: ThreadPool, events: EventEmitter, networkService: network_service.Service): Service = + new(result, delete) + result.QObject.setup + result.threadpool = threadpool + result.events = events + result.networkService = networkService + result.followingAddressesTable = initTable[string, seq[FollowingAddressDto]]() + result.totalFollowingCount = 0 + + proc init*(self: Service) = + discard + + # Forward declaration + proc fetchFollowingStats*(self: Service, userAddress: string) + + proc getFollowingAddresses*(self: Service, userAddress: string): seq[FollowingAddressDto] = + if self.followingAddressesTable.hasKey(userAddress): + return self.followingAddressesTable[userAddress] + return @[] + + proc fetchFollowingAddresses*(self: Service, userAddress: string, search: string = "", limit: int = 10, offset: int = 0) = + # Fetch stats only when not searching (to get total count for pagination) + if search.len == 0: + self.fetchFollowingStats(userAddress) + + let arg = FetchFollowingAddressesTaskArg( + tptr: fetchFollowingAddressesTask, + vptr: cast[uint](self.vptr), + slot: "onFollowingAddressesFetched", + userAddress: userAddress, + search: search, + limit: limit, + offset: offset + ) + self.threadpool.start(arg) + + proc onFollowingAddressesFetched(self: Service, response: string) {.slot.} = + try: + let parsedJson = response.parseJson + + var errorString: string + var userAddress: string + var followingAddressesJson, followingResult: JsonNode + discard parsedJson.getProp("followingAddresses", followingAddressesJson) + discard parsedJson.getProp("userAddress", userAddress) + discard parsedJson.getProp("error", errorString) + + if not errorString.isEmptyOrWhitespace: + error "onFollowingAddressesFetched got error from backend", errorString = errorString + let args = FollowingAddressesArgs(userAddress: userAddress, addresses: @[]) + self.events.emit(SIGNAL_FOLLOWING_ADDRESSES_UPDATED, args) + return + if followingAddressesJson.isNil or followingAddressesJson.kind == JNull: + warn "onFollowingAddressesFetched: followingAddressesJson is nil or null" + let args = FollowingAddressesArgs(userAddress: userAddress, addresses: @[]) + self.events.emit(SIGNAL_FOLLOWING_ADDRESSES_UPDATED, args) + return + + discard followingAddressesJson.getProp("result", followingResult) + if followingResult.isNil or followingResult.kind == JNull: + warn "onFollowingAddressesFetched: followingResult is nil or null" + let args = FollowingAddressesArgs(userAddress: userAddress, addresses: @[]) + self.events.emit(SIGNAL_FOLLOWING_ADDRESSES_UPDATED, args) + return + + let addresses = followingResult.getElems().map(proc(x: JsonNode): FollowingAddressDto = x.toFollowingAddressDto()) + + # Update cache with complete data (ENS names and avatars already included from API) + self.followingAddressesTable[userAddress] = addresses + + # Emit signal to refresh UI - data is complete + let args = FollowingAddressesArgs(userAddress: userAddress, addresses: addresses) + self.events.emit(SIGNAL_FOLLOWING_ADDRESSES_UPDATED, args) + + except Exception as e: + error "onFollowingAddressesFetched exception", msg = e.msg + let args = FollowingAddressesArgs(userAddress: "", addresses: @[]) + self.events.emit(SIGNAL_FOLLOWING_ADDRESSES_UPDATED, args) + + proc getTotalFollowingCount*(self: Service): int = + return self.totalFollowingCount + + proc fetchFollowingStats*(self: Service, userAddress: string) = + try: + let response = following_addresses.getFollowingStats(userAddress) + if response.error.isNil: + self.totalFollowingCount = response.result.getInt() + else: + error "fetchFollowingStats: error", error = response.error + except Exception as e: + error "fetchFollowingStats: exception", msg = e.msg + + proc delete*(self: Service) = + self.QObject.delete + diff --git a/src/backend/following_addresses.nim b/src/backend/following_addresses.nim new file mode 100644 index 00000000000..380554f929f --- /dev/null +++ b/src/backend/following_addresses.nim @@ -0,0 +1,14 @@ +import json +import ./core, ./response_type +export response_type + +from gen import rpc + +rpc(getFollowingAddresses, "wallet"): + userAddress: string + search: string + limit: int + offset: int + +rpc(getFollowingStats, "wallet"): + userAddress: string diff --git a/storybook/pages/FollowingAddressMenuPage.qml b/storybook/pages/FollowingAddressMenuPage.qml new file mode 100644 index 00000000000..d0dd72d6aa0 --- /dev/null +++ b/storybook/pages/FollowingAddressMenuPage.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import StatusQ.Core.Theme + +import AppLayouts.Wallet.controls +import AppLayouts.Wallet.stores as WalletStores + +import Models +import Storybook +import utils + +SplitView { + id: root + + Logs { id: logs } + + Rectangle { + SplitView.fillWidth: true + SplitView.fillHeight: true + color: Theme.palette.statusAppLayout.rightPanelBackgroundColor + + Button { + anchors.centerIn: parent + text: "Show Following Address Menu" + onClicked: menu.popup() + } + + FollowingAddressMenu { + id: menu + anchors.centerIn: parent + + // Pass mock rootStore (using singleton stub) + rootStore: WalletStores.RootStore + + name: nameField.text + address: addressField.text + ensName: ensField.text + tags: tagsField.text.split(",").map(t => t.trim()).filter(t => t.length > 0) + + activeNetworksModel: NetworksModel.flatNetworks + } + } + + Pane { + SplitView.minimumWidth: 350 + SplitView.preferredWidth: 350 + + ScrollView { + anchors.fill: parent + + ColumnLayout { + spacing: 12 + width: parent.width + + Label { + text: "Name:" + font.bold: true + } + TextField { + id: nameField + Layout.fillWidth: true + text: "vitalik.eth" + placeholderText: "Name or ENS" + } + + Label { + text: "Address:" + font.bold: true + } + TextField { + id: addressField + Layout.fillWidth: true + text: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + placeholderText: "0x..." + } + + Label { + text: "ENS Name:" + font.bold: true + } + TextField { + id: ensField + Layout.fillWidth: true + text: "vitalik.eth" + placeholderText: "name.eth" + } + + Label { + text: "Tags (comma separated):" + font.bold: true + } + TextField { + id: tagsField + Layout.fillWidth: true + text: "friend, developer" + placeholderText: "tag1, tag2" + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Event Log:" + font.bold: true + } + + LogsView { + Layout.fillWidth: true + Layout.preferredHeight: 150 + logText: logs.logText + } + } + } + } +} + +// category: Controls +// status: good + diff --git a/storybook/pages/FollowingAddressesDelegatePage.qml b/storybook/pages/FollowingAddressesDelegatePage.qml new file mode 100644 index 00000000000..00b4ed06ba8 --- /dev/null +++ b/storybook/pages/FollowingAddressesDelegatePage.qml @@ -0,0 +1,214 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import StatusQ.Core.Theme + +import AppLayouts.Wallet.controls +import AppLayouts.Wallet.stores as WalletStores +import shared.stores as SharedStores + +import Models +import Storybook +import utils + +SplitView { + id: root + + Logs { id: logs } + + SplitView { + orientation: Qt.Vertical + SplitView.fillWidth: true + SplitView.fillHeight: true + + Rectangle { + SplitView.fillWidth: true + SplitView.fillHeight: true + color: Theme.palette.baseColor3 + + FollowingAddressesDelegate { + id: delegate + + anchors.centerIn: parent + width: 600 + + // Properties + title: titleField.text + address: addressField.text + ensName: ensField.text + tags: tagsField.text.split(",").map(t => t.trim()).filter(t => t.length > 0) + avatar: avatarField.text + + // Stores (mock) + rootStore: WalletStores.RootStore + networkConnectionStore: SharedStores.NetworkConnectionStore {} + activeNetworksModel: NetworksModel.flatNetworks + + // Signals + onClicked: logs.logEvent("delegate clicked") + onMenuRequested: (name, address, ensName, tags) => { + logs.logEvent("menuRequested: name=%1, address=%2, ens=%3, tags=%4" + .arg(name).arg(address).arg(ensName).arg(tags.join(","))) + } + } + } + + LogsView { + SplitView.preferredHeight: 150 + SplitView.fillWidth: true + logText: logs.logText + } + } + + Pane { + SplitView.minimumWidth: 350 + SplitView.preferredWidth: 350 + + ScrollView { + anchors.fill: parent + clip: true + + ColumnLayout { + spacing: 12 + width: parent.width - 20 + + Label { + text: "Delegate Properties" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Title:" + font.bold: true + } + TextField { + id: titleField + Layout.fillWidth: true + text: "vitalik.eth" + placeholderText: "Display name" + } + + Label { + text: "Address:" + font.bold: true + } + TextField { + id: addressField + Layout.fillWidth: true + text: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + placeholderText: "0x..." + } + + Label { + text: "ENS Name:" + font.bold: true + } + TextField { + id: ensField + Layout.fillWidth: true + text: "vitalik.eth" + placeholderText: "name.eth" + } + + Label { + text: "Tags (comma separated):" + font.bold: true + } + TextField { + id: tagsField + Layout.fillWidth: true + text: "friend, developer, ethereum" + placeholderText: "tag1, tag2, tag3" + } + + Label { + text: "Avatar (icon name):" + font.bold: true + } + TextField { + id: avatarField + Layout.fillWidth: true + text: "" + placeholderText: "Leave empty for default" + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Test Scenarios:" + font.pixelSize: 16 + font.bold: true + } + + Button { + Layout.fillWidth: true + text: "Load: Saved Address (ends with 5c)" + onClicked: { + addressField.text = "0x929d0D5Cbc5228543Fa9b7df766CFf42C8c8975c" + titleField.text = "Mock Saved Name" + ensField.text = "" + } + } + + Button { + Layout.fillWidth: true + text: "Load: ENS with No Save" + onClicked: { + addressField.text = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + titleField.text = "vitalik.eth" + ensField.text = "vitalik.eth" + } + } + + Button { + Layout.fillWidth: true + text: "Load: Address Only" + onClicked: { + addressField.text = "0x1234567890123456789012345678901234567890" + titleField.text = "0x1234567890123456789012345678901234567890" + ensField.text = "" + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Tips:" + font.bold: true + } + + Label { + Layout.fillWidth: true + text: "• Addresses ending in '5c' or '42' are mocked as 'saved'\n" + + "• Click delegate to open activity popup\n" + + "• Click menu button to test menu signal\n" + + "• Click star to test save/unsave\n" + + "• Avatar defaults to letter identicon if empty" + wrapMode: Text.WordWrap + font.pixelSize: 12 + color: Theme.palette.baseColor1 + } + } + } + } +} + +// category: Controls +// status: good + diff --git a/storybook/pages/FollowingAddressesViewPage.qml b/storybook/pages/FollowingAddressesViewPage.qml new file mode 100644 index 00000000000..3f62f76228f --- /dev/null +++ b/storybook/pages/FollowingAddressesViewPage.qml @@ -0,0 +1,334 @@ +import QtCore +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import StatusQ.Core.Theme + +import AppLayouts.Wallet.views +import AppLayouts.Wallet.stores as WalletStores +import shared.stores as SharedStores + +import Models +import Storybook +import utils + +SplitView { + id: root + + Logs { id: logs } + + // All available addresses (simulates server database) + ListModel { + id: allAddressesModel + + Component.onCompleted: resetToDefaults() + + function resetToDefaults() { + allAddressesModel.clear() + const addresses = [ + { name: "vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", ensName: "vitalik.eth", tags: ["ethereum", "founder"], avatar: "" }, + { name: "0x929d...8975c", address: "0x929d0D5Cbc5228543Fa9b7df766CFf42C8c8975c", ensName: "", tags: ["saved", "friend"], avatar: "" }, + { name: "alice.eth", address: "0x1234567890123456789012345678901234567890", ensName: "alice.eth", tags: ["defi", "developer"], avatar: "" }, + { name: "bob.eth", address: "0x0987654321098765432109876543210987654321", ensName: "bob.eth", tags: ["nft", "artist"], avatar: "" }, + { name: "charlie.eth", address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", ensName: "charlie.eth", tags: ["dao", "governance"], avatar: "" }, + { name: "0xfedc...4321", address: "0xfedcba9876543210fedcba9876543210fedcba42", ensName: "", tags: [], avatar: "" }, + { name: "david.eth", address: "0x1111111111111111111111111111111111111111", ensName: "david.eth", tags: ["security"], avatar: "" }, + { name: "eve.eth", address: "0x2222222222222222222222222222222222222222", ensName: "eve.eth", tags: ["researcher"], avatar: "" } + ] + addresses.forEach(addr => allAddressesModel.append(addr)) + } + } + + // Current page model (what's actually displayed - simulates API response) + ListModel { + id: currentPageModel + } + + // Mock context property for walletSectionFollowingAddresses + // MUST be defined BEFORE the view so Connections can bind to it + QtObject { + id: mockWalletSection + + property int totalFollowingCount: allAddressesModel.count // Total from "server" + property bool isLoading: loadingCheckbox.checked + + signal followingAddressesUpdated(string userAddress) + } + + // Global context property (set early so view can access it) + property var walletSectionFollowingAddresses: mockWalletSection + + // Function to load a specific page of data (simulates API call) + function loadPage(search, limit, offset) { + currentPageModel.clear() + + const startIdx = offset + const endIdx = Math.min(offset + limit, allAddressesModel.count) + + logs.logEvent("Loading page: offset=%1, limit=%2, total=%3, showing=%4-%5" + .arg(offset).arg(limit).arg(allAddressesModel.count).arg(startIdx).arg(endIdx-1)) + + // Slice the data for current page + for (let i = startIdx; i < endIdx; i++) { + const item = allAddressesModel.get(i) + currentPageModel.append({ + name: item.name, + address: item.address, + ensName: item.ensName, + tags: item.tags, + avatar: item.avatar + }) + } + } + + // Use the RootStore singleton stub and inject our model into it + Component.onCompleted: { + // Inject our current page model (what's displayed) + WalletStores.RootStore.followingAddresses = currentPageModel + WalletStores.RootStore.lastReloadTimestamp = Date.now() / 1000 + // Load initial page + loadPage("", 10, 0) + } + + // Timer to simulate async loading completion + Timer { + id: loadingCompleteTimer + interval: 50 + onTriggered: { + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Loading complete - data refreshed") + } + } + + // Listen to RootStore refresh requests and complete them + Connections { + target: WalletStores.RootStore + + function onRefreshRequested(search, limit, offset) { + logs.logEvent("refreshRequested - search: '%1', limit: %2, offset: %3" + .arg(search).arg(limit).arg(offset)) + // Load the requested page + root.loadPage(search, limit, offset) + // Signal loading complete + loadingCompleteTimer.restart() + } + } + + // Main view area + Rectangle { + SplitView.fillWidth: true + SplitView.fillHeight: true + color: Theme.palette.baseColor3 + + FollowingAddressesView { + id: followingAddressesView + anchors.fill: parent + + rootStore: WalletStores.RootStore // Use the singleton stub + contactsStore: SharedStores.ContactsStore + networkConnectionStore: SharedStores.NetworkConnectionStore {} + networksStore: SharedStores.NetworksStore {} + + onSendToAddressRequested: (address) => { + logs.logEvent("sendToAddressRequested: " + address) + } + + // Trigger initial load after view is created + Component.onCompleted: { + // Delay to let view initialize + loadingCompleteTimer.start() + } + } + } + + + // Control panel + Pane { + SplitView.minimumWidth: 350 + SplitView.preferredWidth: 350 + + ScrollView { + anchors.fill: parent + clip: true + + ColumnLayout { + spacing: 16 + width: parent.width - 20 + + Label { + text: "Following Addresses View" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "View State:" + font.bold: true + } + + CheckBox { + id: loadingCheckbox + text: "Loading state (header spinner)" + checked: false + } + + Label { + text: "Total: %1 | Showing: %2 | Page: ~%3" + .arg(allAddressesModel.count) + .arg(currentPageModel.count) + .arg(Math.floor(currentPageModel.count > 0 ? 1 : 0)) + font.bold: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Data Management:" + font.bold: true + } + + Button { + Layout.fillWidth: true + text: "Add Random Address" + onClicked: { + const randomAddr = "0x" + Math.random().toString(16).substring(2, 42).padEnd(40, '0') + allAddressesModel.append({ + name: "Random " + (allAddressesModel.count + 1), + address: randomAddr, + ensName: "", + tags: ["random"], + avatar: "" + }) + root.loadPage("", 10, 0) // Reload first page + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Added address. Total: " + allAddressesModel.count) + } + } + + Button { + Layout.fillWidth: true + text: "Add 25 Addresses (Test Pagination)" + onClicked: { + for (let i = 0; i < 25; i++) { + const randomAddr = "0x" + Math.random().toString(16).substring(2, 42).padEnd(40, '0') + allAddressesModel.append({ + name: "Test User " + (allAddressesModel.count + 1), + address: randomAddr, + ensName: "", + tags: ["test"], + avatar: "" + }) + } + root.loadPage("", 10, 0) // Reload first page + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Added 25 addresses. Total: " + allAddressesModel.count + " (pagination should appear!)") + } + } + + Button { + Layout.fillWidth: true + text: "Remove Last Address" + enabled: allAddressesModel.count > 0 + onClicked: { + if (allAddressesModel.count > 0) { + allAddressesModel.remove(allAddressesModel.count - 1) + root.loadPage("", 10, 0) // Reload first page + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Removed address. Total: " + allAddressesModel.count) + } + } + } + + Button { + Layout.fillWidth: true + text: "Clear All" + onClicked: { + allAddressesModel.clear() + currentPageModel.clear() + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Cleared all addresses") + } + } + + Button { + Layout.fillWidth: true + text: "Reset to 8 Defaults" + onClicked: { + allAddressesModel.resetToDefaults() + root.loadPage("", 10, 0) // Reload first page + mockWalletSection.followingAddressesUpdated("") + logs.logEvent("Reset to 8 default addresses") + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Features to Test:" + font.bold: true + } + + Label { + Layout.fillWidth: true + text: "Pagination:\n" + + "• Page Size: 10 per page\n" + + "• Click 'Add 25' to test pagination\n" + + "• Navigate pages at bottom\n" + + "• Each page shows ONLY 10 items\n\n" + + "Features:\n" + + "• Search bar - filter by name/address\n" + + "• Click address - opens activity popup\n" + + "• Click menu (...) - more actions\n" + + "• Click star - save/unsave\n" + + "• Click send - opens send modal\n" + + "• Reload button - refreshes current page\n" + + "• Add via EFP - opens EFP website" + wrapMode: Text.WordWrap + font.pixelSize: 12 + color: Theme.palette.baseColor1 + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.palette.baseColor2 + } + + Label { + text: "Event Log:" + font.bold: true + } + + LogsView { + Layout.fillWidth: true + Layout.preferredHeight: 200 + logText: logs.logText + } + } + } + } + + Settings { + property alias loading: loadingCheckbox.checked + } +} + +// category: Views +// status: good diff --git a/storybook/stubs/AppLayouts/Wallet/stores/RootStore.qml b/storybook/stubs/AppLayouts/Wallet/stores/RootStore.qml index af947a2b305..47e516a5e96 100644 --- a/storybook/stubs/AppLayouts/Wallet/stores/RootStore.qml +++ b/storybook/stubs/AppLayouts/Wallet/stores/RootStore.qml @@ -11,6 +11,15 @@ QtObject { property bool showSavedAddresses property bool isAccountTokensReloading + property int lastReloadTimestamp: Date.now() / 1000 + property var followingAddresses: ListModel {} + + // Signals for saved address updates (used by FollowingAddressesDelegate) + signal savedAddressAddedOrUpdated(bool added, string name, string address, string errorMsg) + signal savedAddressDeleted(string name, string address, string errorMsg) + + // Signal for when refresh is called (Storybook can listen to this) + signal refreshRequested(string search, int limit, int offset) // TODO: Remove this. This stub should be empty. The color transformation should be done in adaptors or in the first model transformation steps. @@ -55,4 +64,33 @@ QtObject { function getTransactionType(transaction) { return transaction.txType } + + // Mock function for refreshing following addresses + function refreshFollowingAddresses(search, limit, offset) { + console.log("refreshFollowingAddresses called:", search, limit, offset) + root.lastReloadTimestamp = Date.now() / 1000 + // Emit signal so Storybook can listen and respond + root.refreshRequested(search, limit, offset) + } + + // Mock function for FollowingAddressesDelegate/Menu + function getSavedAddress(address) { + // Return mock saved address data + // Some addresses are "saved", some are not (for testing) + if (address.endsWith("5c") || address.endsWith("42")) { + return { + "address": address, + "name": "Mock Saved Name", + "ens": "", + "colorId": "primary" + } + } + // Not saved + return { + "address": "", + "name": "", + "ens": "", + "colorId": "" + } + } } diff --git a/storybook/stubs/AppLayouts/stores/ContactsStore.qml b/storybook/stubs/AppLayouts/stores/ContactsStore.qml index badcc9e1d1b..3e4f9c9312c 100644 --- a/storybook/stubs/AppLayouts/stores/ContactsStore.qml +++ b/storybook/stubs/AppLayouts/stores/ContactsStore.qml @@ -1,4 +1,7 @@ import QtQuick QtObject { + function getContactPublicKeyByAddress(address) { + return "" + } } diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml index bd6347c9e82..a33082d5483 100644 --- a/ui/app/AppLayouts/Wallet/WalletLayout.qml +++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml @@ -93,7 +93,8 @@ Item { enum LeftPanelSelection { AllAddresses, Address, - SavedAddresses + SavedAddresses, + FollowingAddresses } enum RightPanelSelection { @@ -115,6 +116,7 @@ Item { function openDesiredView(leftPanelSelection, rightPanelSelection, data) { if (leftPanelSelection !== WalletLayout.LeftPanelSelection.AllAddresses && leftPanelSelection !== WalletLayout.LeftPanelSelection.SavedAddresses && + leftPanelSelection !== WalletLayout.LeftPanelSelection.FollowingAddresses && leftPanelSelection !== WalletLayout.LeftPanelSelection.Address) { console.warn("not supported left selection", leftPanelSelection) return @@ -122,6 +124,8 @@ Item { if (leftPanelSelection === WalletLayout.LeftPanelSelection.SavedAddresses) { d.displaySavedAddresses() + } else if (leftPanelSelection === WalletLayout.LeftPanelSelection.FollowingAddresses) { + d.displayFollowingAddresses() } else { let address = data.address ?? "" if (leftPanelSelection === WalletLayout.LeftPanelSelection.AllAddresses) { @@ -162,10 +166,24 @@ Item { onShowSavedAddressesChanged: { if(showSavedAddresses) { rightPanelStackView.replace(cmpSavedAddresses) - } else { + RootStore.backButtonName = "" + } else if (!showFollowingAddresses) { + // Only replace with walletContainer if we're not showing following addresses rightPanelStackView.replace(walletContainer) + RootStore.backButtonName = "" + } + } + + readonly property bool showFollowingAddresses: RootStore.showFollowingAddresses + onShowFollowingAddressesChanged: { + if(showFollowingAddresses) { + rightPanelStackView.replace(cmpFollowingAddresses) + RootStore.backButtonName = "" + } else if (!showSavedAddresses) { + // Only replace with walletContainer if we're not showing saved addresses + rightPanelStackView.replace(walletContainer) + RootStore.backButtonName = "" } - RootStore.backButtonName = "" } property SwapInputParamsForm swapFormData: SwapInputParamsForm { @@ -178,12 +196,14 @@ Item { function displayAllAddresses() { RootStore.showSavedAddresses = false + RootStore.showFollowingAddresses = false RootStore.selectedAddress = "" RootStore.setFilterAllAddresses() } function displayAddress(address) { RootStore.showSavedAddresses = false + RootStore.showFollowingAddresses = false RootStore.selectedAddress = address d.resetRightPanelStackView() // Avoids crashing on asset items being destroyed while in signal handler RootStore.setFilterAddress(address) @@ -191,6 +211,13 @@ Item { function displaySavedAddresses() { RootStore.showSavedAddresses = true + RootStore.showFollowingAddresses = false + RootStore.selectedAddress = "" + } + + function displayFollowingAddresses() { + RootStore.showSavedAddresses = false + RootStore.showFollowingAddresses = true RootStore.selectedAddress = "" } @@ -222,6 +249,21 @@ Item { id: cmpSavedAddresses SavedAddressesView { + rootStore: RootStore + networkConnectionStore: root.networkConnectionStore + networksStore: root.networksStore + + onSendToAddressRequested: { + Global.sendToRecipientRequested(address) + } + } + } + + Component { + id: cmpFollowingAddresses + FollowingAddressesView { + rootStore: RootStore + contactsStore: root.contactsStore networkConnectionStore: root.networkConnectionStore networksStore: root.networksStore @@ -298,6 +340,10 @@ Item { walletSectionLayout.goToNextPanel() d.displaySavedAddresses() } + selectFollowingAddresses: function() { + walletSectionLayout.goToNextPanel() + d.displayFollowingAddresses() + } } centerPanel: StackView { diff --git a/ui/app/AppLayouts/Wallet/controls/FollowingAddressMenu.qml b/ui/app/AppLayouts/Wallet/controls/FollowingAddressMenu.qml new file mode 100644 index 00000000000..468f79ebd81 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/FollowingAddressMenu.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Controls + +import utils + +import StatusQ +import StatusQ.Controls +import StatusQ.Components +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Core.Utils as StatusQUtils +import StatusQ.Popups + +import shared.controls +import shared.popups +import shared.stores as SharedStores + +import "../popups" +import "../controls" + +StatusMenu { + id: root + + property var rootStore // Injected from parent, not singleton + property string name + property string address + property string ensName + property var tags + + // Model providing active networks + // Expected roles: chainId (int), chainName (string), iconUrl (string), layer (int) + property var activeNetworksModel + + QtObject { + id: d + readonly property string visibleAddress: !!root.ensName ? root.ensName : root.address + } + + function openMenu(parent, x, y, model) { + root.name = model.name; + root.address = model.address; + root.ensName = model.ensName; + root.tags = model.tags; + popup(parent, x, y); + } + + onClosed: { + root.name = ""; + root.address = ""; + root.ensName = "" + root.tags = [] + } + + StatusSuccessAction { + id: copyAddressAction + objectName: "copyFollowingAddressAction" + successText: qsTr("Address copied") + text: qsTr("Copy address") + icon.name: "copy" + timeout: 1500 + autoDismissMenu: true + onTriggered: ClipboardUtils.setText(d.visibleAddress) + } + + StatusAction { + text: qsTr("Show address QR") + objectName: "showQrFollowingAddressAction" + assetSettings.name: "qr" + onTriggered: { + Global.openShowQRPopup({ + showSingleAccount: true, + showForSavedAddress: false, + switchingAccounsEnabled: false, + hasFloatingButtons: false, + name: root.name, + address: root.address + }) + } + } + + StatusAction { + text: qsTr("View activity") + objectName: "viewActivityFollowingAddressAction" + assetSettings.name: "wallet" + onTriggered: { + Global.changeAppSectionBySectionType(Constants.appSection.wallet, + WalletLayout.LeftPanelSelection.AllAddresses, + WalletLayout.RightPanelSelection.Activity, + {savedAddress: root.address}) + } + } + + StatusMenuSeparator {} + + BlockchainExplorersMenu { + id: blockchainExplorersMenu + flatNetworks: root.activeNetworksModel + onNetworkClicked: (shortname, isTestnet) => { + let link = Utils.getUrlForAddressOnNetwork(shortname, isTestnet, d.visibleAddress ? d.visibleAddress : root.ensName); + Global.openLinkWithConfirmation(link, StatusQUtils.StringUtils.extractDomainFromLink(link)); + } + } + + StatusMenuSeparator { } + + StatusAction { + readonly property var savedAddr: root.rootStore ? root.rootStore.getSavedAddress(root.address) : null + readonly property bool isSaved: savedAddr && savedAddr.address !== "" + + text: isSaved ? qsTr("Already in saved addresses") : qsTr("Add to saved addresses") + assetSettings.name: isSaved ? "star-icon" : "star-icon-outline" + objectName: "addToSavedAddressesAction" + enabled: !isSaved + onTriggered: { + let nameToUse = root.ensName || root.address + if (root.ensName && root.ensName.includes(".")) { + nameToUse = root.ensName.split(".")[0] + } + + Global.openAddEditSavedAddressesPopup({ + addAddress: true, + address: root.address, + name: nameToUse, + ens: root.ensName + }) + } + } +} + diff --git a/ui/app/AppLayouts/Wallet/controls/FollowingAddressesDelegate.qml b/ui/app/AppLayouts/Wallet/controls/FollowingAddressesDelegate.qml new file mode 100644 index 00000000000..65c57b1658a --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/FollowingAddressesDelegate.qml @@ -0,0 +1,136 @@ +import QtQuick +import QtQuick.Controls + +import utils + +import StatusQ +import StatusQ.Controls +import StatusQ.Components +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Core.Utils as StatusQUtils +import StatusQ.Popups + +import shared.controls +import shared.popups +import shared.stores as SharedStores + +import "../popups" +import "../controls" + +StatusListItem { + id: root + + property SharedStores.NetworkConnectionStore networkConnectionStore + property var rootStore // Injected from parent, not singleton + + // Model providing active networks + // Expected roles: chainId (int), chainName (string), iconUrl (string), layer (int) + property var activeNetworksModel + property string address + property string ensName + property var tags + property string avatar + + property bool showButtons: sensor.containsMouse + + property alias sendButton: sendButton + property alias starButton: starButton + + signal openSendModal(string recipient) + signal menuRequested(string name, string address, string ensName, var tags) + + objectName: title || "followingAddressDelegate" + subTitle: root.address // Always show address (title shows ENS if available) + + border.color: Theme.palette.baseColor5 + + asset { + width: 40 + height: 40 + name: root.avatar || "" + color: Theme.palette.primaryColor1 + isLetterIdenticon: !root.avatar + letterIdenticonBgWithAlpha: true + } + + statusListItemIcon.hoverEnabled: true + + statusListItemComponentsSlot.spacing: 0 + + QtObject { + id: d + + readonly property string visibleAddress: !!root.ensName ? root.ensName : root.address + + property int savedAddressesVersion: 0 + + readonly property bool isAddressSaved: { + savedAddressesVersion + if (!root.rootStore) return false + const savedAddr = root.rootStore.getSavedAddress(root.address) + return savedAddr && savedAddr.address !== "" + } + } + + Connections { + target: root.rootStore + + function onSavedAddressAddedOrUpdated(added, name, address, errorMsg) { + if (address.toLowerCase() === root.address.toLowerCase()) { + d.savedAddressesVersion++ + } + } + + function onSavedAddressDeleted(name, address, errorMsg) { + if (address.toLowerCase() === root.address.toLowerCase()) { + d.savedAddressesVersion++ + } + } + } + + components: [ + StatusRoundButton { + id: sendButton + visible: !!root.title && root.showButtons + type: StatusRoundButton.Type.Quinary + radius: Theme.radius + icon.name: "send" + enabled: root.networkConnectionStore.sendBuyBridgeEnabled + onClicked: root.openSendModal(d.visibleAddress) + }, + StatusRoundButton { + id: starButton + visible: !!root.title && (d.isAddressSaved || root.showButtons) + type: StatusRoundButton.Type.Quinary + radius: Theme.radius + icon.name: d.isAddressSaved ? "star-icon" : "star-icon-outline" + enabled: !d.isAddressSaved + onClicked: { + let nameToUse = root.ensName || root.address + if (root.ensName && root.ensName.includes(".")) { + nameToUse = root.ensName.split(".")[0] + } + + Global.openAddEditSavedAddressesPopup({ + addAddress: true, + address: root.address, + name: nameToUse, + ens: root.ensName + }) + } + }, + StatusRoundButton { + objectName: "followingAddressView_Delegate_menuButton_" + root.title + visible: !!root.title + enabled: root.showButtons + type: StatusRoundButton.Type.Quinary + radius: Theme.radius + icon.name: "more" + onClicked: { + root.menuRequested(root.title, root.address, root.ensName, root.tags) + } + + } + ] +} diff --git a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml index 96745b0849e..8946e65c7f3 100644 --- a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml +++ b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml @@ -30,6 +30,8 @@ StatusListItem { property string mixedcaseAddress property string ens property string colorId + property string avatar // Optional ENS avatar URL + property bool isFollowingAddress: false // True if from EFP following list, false if saved address property int usage: SavedAddressesDelegate.Usage.Delegate property bool showButtons: sensor.containsMouse @@ -63,8 +65,9 @@ StatusListItem { asset { width: 40 height: 40 + name: root.avatar || "" // Use avatar URL if available color: Utils.getColorForId(root.colorId) - isLetterIdenticon: true + isLetterIdenticon: !root.avatar // Only use letter identicon if no avatar letterIdenticonBgWithAlpha: true } @@ -156,6 +159,7 @@ StatusListItem { text: qsTr("Edit saved address") objectName: "editSavedAddress" assetSettings.name: "pencil-outline" + enabled: !root.isFollowingAddress onTriggered: { if (root.usage === SavedAddressesDelegate.Usage.Item) { root.aboutToOpenPopup() @@ -238,6 +242,7 @@ StatusListItem { type: StatusAction.Type.Danger assetSettings.name: "delete" objectName: "deleteSavedAddress" + enabled: !root.isFollowingAddress onTriggered: { if (root.usage === SavedAddressesDelegate.Usage.Item) { root.aboutToOpenPopup() diff --git a/ui/app/AppLayouts/Wallet/controls/qmldir b/ui/app/AppLayouts/Wallet/controls/qmldir index 23ea6693048..c5cc5d53c04 100644 --- a/ui/app/AppLayouts/Wallet/controls/qmldir +++ b/ui/app/AppLayouts/Wallet/controls/qmldir @@ -10,6 +10,8 @@ CollectibleLinksTags 1.0 CollectibleLinksTags.qml DappsComboBox 1.0 DappsComboBox.qml EditSlippagePanel 1.0 EditSlippagePanel.qml FilterComboBox 1.0 FilterComboBox.qml +FollowingAddressesDelegate 1.0 FollowingAddressesDelegate.qml +FollowingAddressMenu 1.0 FollowingAddressMenu.qml InformationTileAssetDetails 1.0 InformationTileAssetDetails.qml ManageTokenMenuButton 1.0 ManageTokenMenuButton.qml ManageTokensCommunityTag 1.0 ManageTokensCommunityTag.qml diff --git a/ui/app/AppLayouts/Wallet/panels/WalletFollowingAddressesHeader.qml b/ui/app/AppLayouts/Wallet/panels/WalletFollowingAddressesHeader.qml new file mode 100644 index 00000000000..2c1f6c28b4c --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/WalletFollowingAddressesHeader.qml @@ -0,0 +1,158 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQml + +import StatusQ +import StatusQ.Core +import StatusQ.Controls +import StatusQ.Components +import StatusQ.Core.Theme +import StatusQ.Core.Utils as SQUtils + +import AppLayouts.Wallet.controls + +import utils + +Control { + id: root + + /* Formatted time of last reload */ + property string lastReloadedTime + + /* Indicates whether the content is being loaded */ + property bool loading + + /* Emitted when balance reloading is requested explicitly by the user */ + signal reloadRequested + + /* Emitted when add via EFP button is clicked */ + signal addViaEFPClicked + + QtObject { + id: d + + readonly property bool compact: root.width < 600 && + root.availableWidth - headerButton.width - reloadButton.width - titleRow.spacing * 2 < titleText.implicitWidth + + //throttle for 1 min + readonly property int reloadThrottleTimeMs: 1000 * 60 + } + + StatusButton { + id: headerButton + + objectName: "walletHeaderButton" + + Layout.preferredHeight: 38 + + text: qsTr("Add via EFP") + size: StatusBaseButton.Size.Small + normalColor: Theme.palette.primaryColor3 + hoverColor: Theme.palette.primaryColor2 + + onClicked: root.addViaEFPClicked() + } + + RowLayout { + id: titleRow + + spacing: Theme.padding + + StatusBaseText { + id: titleText + + objectName: "walletHeaderTitle" + + Layout.fillWidth: true + + elide: Text.ElideRight + + font.pixelSize: Theme.fontSize19 + font.weight: Font.Medium + + text: qsTr("EFP onchain friends") + lineHeightMode: Text.FixedHeight + lineHeight: 26 + } + + StatusButton { + id: reloadButton + size: StatusBaseButton.Size.Tiny + + Layout.preferredHeight: 38 + Layout.preferredWidth: 38 + Layout.alignment: Qt.AlignVCenter + + borderColor: Theme.palette.directColor7 + borderWidth: 1 + + normalColor: Theme.palette.transparent + hoverColor: Theme.palette.baseColor2 + + icon.name: "refresh" + icon.color: { + if (!interactive) { + return Theme.palette.baseColor1; + } + if (hovered) { + return Theme.palette.directColor1; + } + + return Theme.palette.baseColor1; + } + asset.mirror: true + + tooltip.text: qsTr("Last refreshed %1").arg(root.lastReloadedTime) + + loading: root.loading + interactive: !loading && !throttleTimer.running + + onClicked: root.reloadRequested() + + Timer { + id: throttleTimer + + interval: d.reloadThrottleTimeMs + + // Start the timer immediately to disable manual reload initially, + // as automatic refresh is performed upon entering the wallet. + running: true + } + + Connections { + target: root + + function onLastReloadedTimeChanged() { + // Start the throttle timer whenever the tokens are reloaded, + // which can be triggered by either automatic or manual reload. + throttleTimer.restart() + } + } + } + + LayoutItemProxy { + visible: !d.compact + target: headerButton + } + } + + contentItem: ColumnLayout { + spacing: Theme.padding + + LayoutItemProxy { + Layout.fillWidth: true + + target: titleRow + } + + LayoutItemProxy { + Layout.alignment: Qt.AlignRight + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + visible: d.compact + target: headerButton + } + } +} + diff --git a/ui/app/AppLayouts/Wallet/panels/qmldir b/ui/app/AppLayouts/Wallet/panels/qmldir index 2f032df4666..4c99fc26e3e 100644 --- a/ui/app/AppLayouts/Wallet/panels/qmldir +++ b/ui/app/AppLayouts/Wallet/panels/qmldir @@ -19,4 +19,5 @@ StickySendModalHeader 1.0 StickySendModalHeader.qml SwapInputPanel 1.0 SwapInputPanel.qml TokenSelectorPanel 1.0 TokenSelectorPanel.qml WalletAccountHeader 1.0 WalletAccountHeader.qml +WalletFollowingAddressesHeader 1.0 WalletFollowingAddressesHeader.qml WalletSavedAddressesHeader 1.0 WalletSavedAddressesHeader.qml diff --git a/ui/app/AppLayouts/Wallet/popups/SavedAddressActivityPopup.qml b/ui/app/AppLayouts/Wallet/popups/SavedAddressActivityPopup.qml index bf91ca18500..f9277b0c18d 100644 --- a/ui/app/AppLayouts/Wallet/popups/SavedAddressActivityPopup.qml +++ b/ui/app/AppLayouts/Wallet/popups/SavedAddressActivityPopup.qml @@ -43,6 +43,8 @@ StatusDialog { d.mixedcaseAddress = params.mixedcaseAddress?? Constants.zeroAddress d.ens = params.ens?? "" d.colorId = params.colorId?? "" + d.avatar = params.avatar?? "" + d.isFollowingAddress = params.isFollowingAddress?? false walletSection.activityController.setFilterToAddresses(JSON.stringify([d.address])) walletSection.activityController.updateFilter() @@ -59,6 +61,8 @@ StatusDialog { property string mixedcaseAddress: Constants.zeroAddress property string ens: "" property string colorId: "" + property string avatar: "" + property bool isFollowingAddress: false readonly property string visibleAddress: !!d.ens? d.ens : d.address @@ -135,6 +139,8 @@ StatusDialog { ens: d.ens colorId: d.colorId mixedcaseAddress: d.mixedcaseAddress + avatar: d.avatar + isFollowingAddress: d.isFollowingAddress statusListItemTitle.font.pixelSize: Theme.fontSize22 statusListItemTitle.font.bold: Font.Bold diff --git a/ui/app/AppLayouts/Wallet/stores/RootStore.qml b/ui/app/AppLayouts/Wallet/stores/RootStore.qml index 86ae0f56989..354bf12dc29 100644 --- a/ui/app/AppLayouts/Wallet/stores/RootStore.qml +++ b/ui/app/AppLayouts/Wallet/stores/RootStore.qml @@ -16,8 +16,9 @@ QtObject { id: root property bool showSavedAddresses: false + property bool showFollowingAddresses: false property string selectedAddress: "" - readonly property bool showAllAccounts: !root.showSavedAddresses && !root.selectedAddress + readonly property bool showAllAccounts: !root.showSavedAddresses && !root.showFollowingAddresses && !root.selectedAddress property var lastCreatedSavedAddress property bool addingSavedAddress: false @@ -63,6 +64,23 @@ QtObject { ] } + readonly property var followingAddresses: walletSectionFollowingAddresses ? walletSectionFollowingAddresses.model : null + + function refreshFollowingAddresses(search, limit, offset) { + if (!walletSectionFollowingAddresses) return + const primaryAddress = getPrimaryAccountAddress() + if (primaryAddress) { + search = search || "" + limit = limit || 10 + offset = offset || 0 + walletSectionFollowingAddresses.fetchFollowingAddresses(primaryAddress, search, limit, offset) + } + } + + function getPrimaryAccountAddress() { + return SQUtils.ModelUtils.get(root.accounts, 0, "address") || "" + } + property var nonWatchAccounts: SortFilterProxyModel { sourceModel: accounts proxyRoles: [ @@ -144,7 +162,7 @@ QtObject { target: root.walletSectionInst function onWalletAccountRemoved(address) { address = address.toLowerCase(); - for (var addressKey in d.activityFiltersStoreDictionary){ + for (const addressKey in d.activityFiltersStoreDictionary){ if (address === addressKey.toLowerCase()){ delete d.activityFiltersStoreDictionary[addressKey] return @@ -280,7 +298,7 @@ QtObject { } function getNameForAddress(address) { - var name = getNameForWalletAddress(address) + let name = getNameForWalletAddress(address) if (name.length === 0) { let savedAddress = getSavedAddress(address) name = savedAddress.name diff --git a/ui/app/AppLayouts/Wallet/views/FollowingAddresses.qml b/ui/app/AppLayouts/Wallet/views/FollowingAddresses.qml new file mode 100644 index 00000000000..8e111f7331f --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/FollowingAddresses.qml @@ -0,0 +1,219 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import StatusQ.Components +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Controls.Validators + +import SortFilterProxyModel + +import utils +import shared.controls +import shared.stores as SharedStores +import AppLayouts.stores as AppLayoutStores + +import AppLayouts.Wallet.stores as WalletStores +import AppLayouts.Wallet.controls + +Item { + id: root + + required property AppLayoutStores.ContactsStore contactsStore + required property SharedStores.NetworkConnectionStore networkConnectionStore + required property SharedStores.NetworksStore networksStore + required property var followingAddressesModel + required property int totalFollowingCount + required property WalletStores.RootStore rootStore // Wallet-specific RootStore for delegates + + signal sendToAddressRequested(string address) + signal refreshRequested(string search, int limit, int offset) + signal followingAddressesUpdated() + + readonly property bool showPagination: !d.currentSearch && root.totalFollowingCount > d.pageSize + readonly property int pageSize: d.pageSize + readonly property int totalCount: root.totalFollowingCount + readonly property int currentPage: d.currentPage + readonly property bool isPaginationLoading: d.isPaginationLoading + + function goToPage(pageNumber) { + d.goToPage(pageNumber) + } + + function refresh() { + d.refresh() + } + + QtObject { + id: d + + property string currentSearch: "" + property int pageSize: 10 + property int currentPage: 1 + property bool isPaginationLoading: false + + function reset() { + currentSearch = "" + searchBox.text = "" + currentPage = 1 + } + + function performSearch() { + const offset = (currentPage - 1) * pageSize + isPaginationLoading = true + root.refreshRequested(currentSearch, pageSize, offset) + } + + function goToPage(pageNumber) { + currentPage = pageNumber + performSearch() + } + + function refresh() { + currentPage = 1 + currentSearch = "" + searchBox.text = "" + isPaginationLoading = true + root.refreshRequested("", pageSize, 0) + } + } + + // Called from parent when following addresses are updated + onFollowingAddressesUpdated: { + d.isPaginationLoading = false + } + + Component.onCompleted: { + d.refresh() // Load data when user navigates to this page + } + + Timer { + id: searchDebounceTimer + interval: 250 + repeat: false + onTriggered: d.performSearch() + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + SearchBox { + id: searchBox + Layout.fillWidth: true + Layout.bottomMargin: Theme.padding + visible: true + placeholderText: qsTr("Search for name, ENS or address") + + onTextChanged: { + d.currentSearch = text + d.currentPage = 1 + searchDebounceTimer.restart() + } + + validators: [ + StatusValidator { + property bool isEmoji: false + + name: "check-for-no-emojis" + validate: (value) => { + if (!value) { + return true + } + + isEmoji = Constants.regularExpressions.emoji.test(value) + if (isEmoji){ + return false + } + + return Constants.regularExpressions.alphanumericalExpanded1.test(value) + } + errorMessage: isEmoji? + qsTr("Your search is too cool (use A-Z and 0-9, single whitespace, hyphens and underscores only)") + : qsTr("Your search contains invalid characters (use A-Z and 0-9, single whitespace, hyphens and underscores only)") + } + ] + } + + ShapeRectangle { + id: noFollowingAddresses + Layout.fillWidth: true + Layout.preferredHeight: 44 + visible: root.followingAddressesModel && root.followingAddressesModel.count === 0 && !d.isPaginationLoading + text: qsTr("Your EFP onchain friends will appear here") + } + + ShapeRectangle { + id: emptySearchResult + Layout.fillWidth: true + Layout.preferredHeight: 44 + visible: root.followingAddressesModel && root.followingAddressesModel.count > 0 && listView.count === 0 && !d.isPaginationLoading + text: qsTr("No following addresses found. Check spelling or whether the address is correct.") + } + + Item { + visible: noFollowingAddresses.visible || emptySearchResult.visible + Layout.fillWidth: true + Layout.fillHeight: true + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + StatusListView { + id: listView + objectName: "FollowingAddressesView_followingAddresses" + anchors.fill: parent + spacing: Theme.halfPadding + visible: !d.isPaginationLoading + + model: root.followingAddressesModel + + delegate: FollowingAddressesDelegate { + id: followingAddressDelegate + objectName: "followingAddressView_Delegate_" + model.name + width: ListView.view.width + title: model.name + address: model.address + ensName: model.ensName + tags: model.tags + avatar: model.avatar + networkConnectionStore: root.networkConnectionStore + activeNetworksModel: root.networksStore.activeNetworks + rootStore: root.rootStore + onClicked: { + Global.openSavedAddressActivityPopup({ + name: model.name, + address: model.address, + ens: model.ensName, + colorId: "", + avatar: model.avatar, + isFollowingAddress: true + }) + } + onOpenSendModal: root.sendToAddressRequested(recipient) + onMenuRequested: (name, address, ensName, tags) => { + followingAddressMenu.openMenu(followingAddressDelegate, + followingAddressDelegate.width - followingAddressMenu.width, + followingAddressDelegate.height + Theme.halfPadding, + {name: name, address: address, ensName: ensName, tags: tags}) + } + } + } + + StatusLoadingIndicator { + anchors.centerIn: parent + visible: d.isPaginationLoading + color: Theme.palette.directColor4 + } + } + + FollowingAddressMenu { + id: followingAddressMenu + activeNetworksModel: root.networksStore.activeNetworks + rootStore: root.rootStore + } + } +} diff --git a/ui/app/AppLayouts/Wallet/views/FollowingAddressesView.qml b/ui/app/AppLayouts/Wallet/views/FollowingAddressesView.qml new file mode 100644 index 00000000000..33dc1e91078 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/FollowingAddressesView.qml @@ -0,0 +1,97 @@ +import QtQuick +import QtQuick.Layouts + +import StatusQ.Core.Theme +import StatusQ.Core.Utils as StatusQUtils +import StatusQ.Core + +import AppLayouts.Wallet.stores as WalletStores +import AppLayouts.Wallet.panels +import AppLayouts.Market.controls + +import shared.stores as SharedStores +import utils + +RightTabBaseView { + id: root + + signal sendToAddressRequested(string address) + + required property WalletStores.RootStore rootStore + required property var contactsStore + required property SharedStores.NetworkConnectionStore networkConnectionStore + required property SharedStores.NetworksStore networksStore + + header: WalletFollowingAddressesHeader { + lastReloadedTime: !!root.rootStore.lastReloadTimestamp ? + LocaleUtils.formatRelativeTimestamp( + root.rootStore.lastReloadTimestamp * 1000) : "" + loading: followingAddresses.isPaginationLoading + + onReloadRequested: followingAddresses.refresh() + onAddViaEFPClicked: Global.requestOpenLink("https://efp.app") + } + + Item { + anchors.fill: parent + + FollowingAddresses { + id: followingAddresses + objectName: "followingAddressesArea" + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: paginationFooter.visible ? paginationFooter.top : parent.bottom + + contactsStore: root.contactsStore + networkConnectionStore: root.networkConnectionStore + networksStore: root.networksStore + followingAddressesModel: root.rootStore.followingAddresses + totalFollowingCount: walletSectionFollowingAddresses ? + walletSectionFollowingAddresses.totalFollowingCount : 0 + rootStore: root.rootStore + + onSendToAddressRequested: root.sendToAddressRequested(address) + + onRefreshRequested: (search, limit, offset) => { + root.rootStore.refreshFollowingAddresses(search, limit, offset) + } + + Connections { + target: walletSectionFollowingAddresses + function onFollowingAddressesUpdated() { + followingAddresses.followingAddressesUpdated() + } + } + } + + // Full-width divider above pagination (extends beyond content padding) + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: -(Theme.xlPadding * 2) + anchors.rightMargin: -(Theme.xlPadding * 2) + anchors.bottom: paginationFooter.top + height: 1 + color: Theme.palette.baseColor2 + visible: paginationFooter.visible + } + + // Sticky pagination footer at bottom (extends beyond content padding) + Paginator { + id: paginationFooter + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: -(Theme.halfPadding + 2) + topPadding: Theme.padding + bottomPadding: Theme.padding + visible: followingAddresses.showPagination + + pageSize: followingAddresses.pageSize + totalCount: followingAddresses.totalCount + currentPage: followingAddresses.currentPage + enabled: !followingAddresses.isPaginationLoading + onRequestPage: followingAddresses.goToPage(pageNumber) + } + } +} diff --git a/ui/app/AppLayouts/Wallet/views/LeftTabView.qml b/ui/app/AppLayouts/Wallet/views/LeftTabView.qml index 66b03b66e4e..a24879338e5 100644 --- a/ui/app/AppLayouts/Wallet/views/LeftTabView.qml +++ b/ui/app/AppLayouts/Wallet/views/LeftTabView.qml @@ -33,6 +33,7 @@ Rectangle { property var selectAllAccounts: function(){} property var changeSelectedAccount: function(){} property var selectSavedAddresses: function(){} + property var selectFollowingAddresses: function(){} property var emojiPopup: null property bool isKeycardEnabled: true @@ -463,13 +464,35 @@ Rectangle { spacing: walletAccountsListView.firstItem?.statusListItemTitleArea.anchors.leftMargin ?? Theme.padding onClicked: root.selectSavedAddresses() } + } + + Control { + id: followingAddressesFooter + + anchors { + top: footer.bottom + left: parent.left + right: parent.right + } + + horizontalPadding: Theme.padding + verticalPadding: 0 - StatusMouseArea { - anchors.fill: parent - acceptedButtons: Qt.RightButton - cursorShape: Qt.PointingHandCursor - propagateComposedEvents: true - onClicked: (mouse) => mouse.accepted = true + contentItem: StatusFlatButton { + objectName: "followingAddressesBtn" + highlighted: RootStore.showFollowingAddresses + hoverColor: Theme.palette.backgroundHover + asset.bgColor: Theme.palette.primaryColor3 + text: qsTr("EFP onchain friends") + icon.name: "contact" + icon.width: 40 + icon.height: 40 + icon.color: Theme.palette.primaryColor1 + isRoundIcon: true + textColor: Theme.palette.directColor1 + textFillWidth: true + spacing: walletAccountsListView.firstItem?.statusListItemTitleArea.anchors.leftMargin ?? Theme.padding + onClicked: root.selectFollowingAddresses() } } } diff --git a/ui/app/AppLayouts/Wallet/views/qmldir b/ui/app/AppLayouts/Wallet/views/qmldir index aa6fae11b2b..e091842d048 100644 --- a/ui/app/AppLayouts/Wallet/views/qmldir +++ b/ui/app/AppLayouts/Wallet/views/qmldir @@ -1,5 +1,7 @@ AssetsDetailView 1.0 AssetsDetailView.qml CollectiblesView 1.0 CollectiblesView.qml +FollowingAddresses 1.0 FollowingAddresses.qml +FollowingAddressesView 1.0 FollowingAddressesView.qml NetworkSelectorView 1.0 NetworkSelectorView.qml SavedAddresses 1.0 SavedAddresses.qml TokenSelectorAssetDelegate 1.0 TokenSelectorAssetDelegate.qml diff --git a/ui/app/AppLayouts/stores/RootStore.qml b/ui/app/AppLayouts/stores/RootStore.qml index 1d1762eb8f0..70499e58a00 100644 --- a/ui/app/AppLayouts/stores/RootStore.qml +++ b/ui/app/AppLayouts/stores/RootStore.qml @@ -300,6 +300,7 @@ QtObject { // Wallet related properties and functions that shall be moved to `WalletRootStore` property var walletSectionSendInst: walletSectionSend property var savedAddressesModel: walletSectionSavedAddresses.model + readonly property var followingAddressesModel: walletSectionFollowingAddresses.model readonly property var accounts: walletSectionAccounts.accounts property var walletAccountsModel: WalletStore.RootStore.nonWatchAccounts diff --git a/vendor/status-go b/vendor/status-go index 641a18b6f61..4c3fda7768e 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit 641a18b6f61cb989fb52e4df823349ae56fd05ef +Subproject commit 4c3fda7768ed7d35abb187877aac5962e3ee19bc