Show more collection-level data from OpenSea #3730

pull/3858/head
Krypto Pank 3 years ago
parent d522837bc4
commit 2b7e3e5331
  1. 73
      AlphaWallet.xcodeproj/project.pbxproj
  2. 2
      AlphaWallet/Alerts/Views/PriceAlertsPageView.swift
  3. 7
      AlphaWallet/Browser/Coordinators/QRCodeResolutionCoordinator.swift
  4. 161
      AlphaWallet/Core/Coordinators/WalletBalance/PrivateBalanceFetcher.swift
  5. 2
      AlphaWallet/Core/Coordinators/WalletBalance/WalletBalanceCoordinator.swift
  6. 23
      AlphaWallet/Core/Coordinators/WalletBalance/WalletBalanceFetcher.swift
  7. 6
      AlphaWallet/Core/Helpers/BlockieGenerator.swift
  8. 112
      AlphaWallet/Core/OpenSea/Helpers/OpenSeaAssetDecoder.swift
  9. 277
      AlphaWallet/Core/OpenSea/OpenSea.swift
  10. 112
      AlphaWallet/Core/OpenSea/Types/OpenSeaCollection.swift
  11. 14
      AlphaWallet/Core/OpenSea/Types/OpenSeaNonFungible.swift
  12. 0
      AlphaWallet/Core/OpenSea/Types/OpenSeaNonFungibleTokenHandling.swift
  13. 68
      AlphaWallet/Core/OpenSea/Types/OpenSeaStats.swift
  14. 1
      AlphaWallet/Core/Types/TokenProviderType.swift
  15. 0
      AlphaWallet/Core/Views/BlockchainTagLabel.swift
  16. 39
      AlphaWallet/Core/Views/SelfResizableCollectionView.swift
  17. 0
      AlphaWallet/Extensions/WKWebViewExtension.swift
  18. 10
      AlphaWallet/Foundation/Formatter.swift
  19. 4
      AlphaWallet/Foundation/StringFormatter.swift
  20. 29
      AlphaWallet/Localization/en.lproj/Localizable.strings
  21. 29
      AlphaWallet/Localization/es.lproj/Localizable.strings
  22. 27
      AlphaWallet/Localization/fi.lproj/Localizable.strings
  23. 29
      AlphaWallet/Localization/ja.lproj/Localizable.strings
  24. 29
      AlphaWallet/Localization/ko.lproj/Localizable.strings
  25. 29
      AlphaWallet/Localization/zh-Hans.lproj/Localizable.strings
  26. 30
      AlphaWallet/Settings/Types/ConfigExplorer.swift
  27. 53
      AlphaWallet/Settings/Types/ServiceProvider.swift
  28. 60
      AlphaWallet/TokenScriptClient/Models/AssetAttributeSyntaxValue.swift
  29. 33
      AlphaWallet/Tokens/Collectibles/Coordinators/TokensCardCollectionCoordinator.swift
  30. 50
      AlphaWallet/Tokens/Collectibles/ViewModels/NonFungibleTraitViewModel.swift
  31. 34
      AlphaWallet/Tokens/Collectibles/ViewModels/TokenInstanceAttributeViewModel.swift
  32. 146
      AlphaWallet/Tokens/Collectibles/ViewModels/TokensCardCollectionInfoPageViewModel.swift
  33. 2
      AlphaWallet/Tokens/Collectibles/Views/AssetsPageView.swift
  34. 90
      AlphaWallet/Tokens/Collectibles/Views/NonFungibleTraitView.swift
  35. 7
      AlphaWallet/Tokens/Collectibles/Views/TokenInstanceAttributeView.swift
  36. 43
      AlphaWallet/Tokens/Collectibles/Views/TokensCardCollectionInfoPageView.swift
  37. 3
      AlphaWallet/Tokens/Helpers/TokenAdaptor.swift
  38. 296
      AlphaWallet/Tokens/Helpers/TokenInstanceViewConfigurationHelper.swift
  39. 0
      AlphaWallet/Tokens/Helpers/UiTweaks.swift
  40. 0
      AlphaWallet/Tokens/Types/HiddenContract.swift
  41. 3
      AlphaWallet/Tokens/Types/NonFungibleFromJson.swift
  42. 12
      AlphaWallet/Tokens/Types/NonFungibleFromTokenUri.swift
  43. 57
      AlphaWallet/Tokens/ViewControllers/TokenInstanceViewController.swift
  44. 217
      AlphaWallet/Tokens/ViewControllers/TokensCardViewController.swift
  45. 6
      AlphaWallet/Tokens/ViewModels/TokenInstanceInfoPageViewModel.swift
  46. 104
      AlphaWallet/Tokens/ViewModels/TokenInstanceViewModel.swift
  47. 32
      AlphaWallet/Tokens/ViewModels/TokensCardViewModel.swift
  48. 74
      AlphaWallet/Tokens/Views/OpenSea/OpenSeaAttributeCollectionView.swift
  49. 12
      AlphaWallet/Tokens/Views/TokenInstanceInfoPageView.swift
  50. 26
      AlphaWallet/Transactions/Coordinators/AlphaWalletProviderFactory.swift
  51. 8
      AlphaWallet/Transactions/Coordinators/TokensCardCoordinator.swift
  52. 4
      AlphaWallet/Transactions/ViewModels/TransactionDetailsViewModel.swift
  53. 2
      AlphaWallet/Transactions/Views/ActivityPageView.swift
  54. 2
      AlphaWallet/Transactions/Views/TokenInfoPageView.swift
  55. 4
      AlphaWallet/Transactions/Views/TokenPagesContainerView.swift

@ -723,8 +723,6 @@
8499D446C6ADE858FE633096 /* GetERC721ForTicketsBalanceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8499DFC3A9D0B1E42D74905E /* GetERC721ForTicketsBalanceCoordinator.swift */; };
8499D8751F2D70FBD13F3C93 /* GetERC721ForTicketsBalance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8499DA6A661E357C9D69BA18 /* GetERC721ForTicketsBalance.swift */; };
8499DAB3D06965FDC1E8FCAF /* GetInterfaceSupported165Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8499D1861F30C1360B1047CE /* GetInterfaceSupported165Coordinator.swift */; };
87012F442797F9F6002BCDF0 /* OpenSeaAttributeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87012F432797F9F6002BCDF0 /* OpenSeaAttributeCollectionView.swift */; };
87012F462797FA97002BCDF0 /* SelfResizableCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87012F452797FA97002BCDF0 /* SelfResizableCollectionView.swift */; };
8703F664261319B80082EE25 /* AddressAndRPCServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8703F663261319B80082EE25 /* AddressAndRPCServer.swift */; };
8703F66726135B330082EE25 /* ChartHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8703F66626135B330082EE25 /* ChartHistory.swift */; };
8703F66B26136A500082EE25 /* RenameWalletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8703F66A26136A500082EE25 /* RenameWalletViewController.swift */; };
@ -774,7 +772,6 @@
8728FFDF27428FEA008E5524 /* TokenInstanceAttributeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8728FFDE27428FEA008E5524 /* TokenInstanceAttributeViewModel.swift */; };
8728FFE12742900A008E5524 /* SingleTokenCardSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8728FFE02742900A008E5524 /* SingleTokenCardSelectionViewModel.swift */; };
8728FFE32742906A008E5524 /* TokenCardTableViewCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8728FFE22742906A008E5524 /* TokenCardTableViewCellFactory.swift */; };
8728FFE527429967008E5524 /* TokensCardCollectionViewControllerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8728FFE427429966008E5524 /* TokensCardCollectionViewControllerViewModel.swift */; };
8728FFEA2743CAA7008E5524 /* TokenCardSelectionSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8728FFE92743CAA7008E5524 /* TokenCardSelectionSectionHeaderView.swift */; };
872D9564271D633E00870971 /* WalletConnectConnectionTimeoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872D9563271D633D00870971 /* WalletConnectConnectionTimeoutViewController.swift */; };
872D9567271D66C200870971 /* GasPriceEstimates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872D9565271D66C200870971 /* GasPriceEstimates.swift */; };
@ -793,7 +790,6 @@
8739BB6026CCED010045CFED /* SelectableTokenCardContainerTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BB5F26CCED010045CFED /* SelectableTokenCardContainerTableViewCellViewModel.swift */; };
8739BB6626CCED4B0045CFED /* AssetsPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BB6526CCED4B0045CFED /* AssetsPageView.swift */; };
8739BB6C26CCED670045CFED /* AssetsPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BB6B26CCED670045CFED /* AssetsPageViewModel.swift */; };
8739BB7326CCEDA10045CFED /* TokensCardCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BB7226CCEDA10045CFED /* TokensCardCollectionViewController.swift */; };
8739BB8F26CCF2F70045CFED /* ActivitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BB8E26CCF2F70045CFED /* ActivitiesView.swift */; };
8739BBC726CD29510045CFED /* TokenCardSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BBC626CD29510045CFED /* TokenCardSelectionViewController.swift */; };
8739BBCD26CD298B0045CFED /* TokenCardSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8739BBCC26CD298B0045CFED /* TokenCardSelectionViewModel.swift */; };
@ -837,6 +833,8 @@
8759C9EB279BD7C300FE361F /* JsonWalletAddressesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8759C9EA279BD7C300FE361F /* JsonWalletAddressesStore.swift */; };
8759C9ED279E9D6100FE361F /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8759C9EC279E9D6100FE361F /* TextFieldView.swift */; };
875B3C34250A75FA0085BD08 /* QRCodeResolutionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875B3C33250A75FA0085BD08 /* QRCodeResolutionCoordinator.swift */; };
875F867F27ABC7A60071ABD1 /* NonFungibleTraitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875F867E27ABC7A60071ABD1 /* NonFungibleTraitViewModel.swift */; };
875F868127ABC7C80071ABD1 /* NonFungibleTraitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875F868027ABC7C80071ABD1 /* NonFungibleTraitView.swift */; };
87620024266E00F70059B05A /* known_contract.json in Resources */ = {isa = PBXBuildFile; fileRef = 87620023266E00F60059B05A /* known_contract.json */; };
87620026266E07490059B05A /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87620025266E07490059B05A /* ThreadSafeDictionary.swift */; };
87620028266E14A80059B05A /* PopularTokenViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87620027266E14A80059B05A /* PopularTokenViewCell.swift */; };
@ -944,6 +942,9 @@
87BC890E26B7EDC6005482F4 /* WalletSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87BC890D26B7EDC6005482F4 /* WalletSummaryView.swift */; };
87BC89B826B82288005482F4 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87BC89B726B82287005482F4 /* UIBarButtonItem.swift */; };
87C10A6525ED1105008E9B1B /* SignatureConfirmationDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C10A6425ED1105008E9B1B /* SignatureConfirmationDetailsViewController.swift */; };
87C2008C27A87FD700338D26 /* OpenSeaCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C2008B27A87FD700338D26 /* OpenSeaCollection.swift */; };
87C2008E27A8802900338D26 /* OpenSeaStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C2008D27A8802900338D26 /* OpenSeaStats.swift */; };
87C2009127A8807400338D26 /* OpenSeaAssetDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C2009027A8807400338D26 /* OpenSeaAssetDecoder.swift */; };
87C237DC26DE5303003CA387 /* UIToolbar+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C237DB26DE5303003CA387 /* UIToolbar+Custom.swift */; };
87C237DE26DE5389003CA387 /* SelectAssetAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C237DD26DE5389003CA387 /* SelectAssetAmountView.swift */; };
87C447AA255970C5009DF2D2 /* ActiveWalletSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C447A9255970C5009DF2D2 /* ActiveWalletSessionView.swift */; };
@ -1812,8 +1813,6 @@
8499DA6A661E357C9D69BA18 /* GetERC721ForTicketsBalance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetERC721ForTicketsBalance.swift; sourceTree = "<group>"; };
8499DDC9B09F49E7799DA0E1 /* SmartContractHelperTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SmartContractHelperTests.swift; sourceTree = "<group>"; };
8499DFC3A9D0B1E42D74905E /* GetERC721ForTicketsBalanceCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetERC721ForTicketsBalanceCoordinator.swift; sourceTree = "<group>"; };
87012F432797F9F6002BCDF0 /* OpenSeaAttributeCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSeaAttributeCollectionView.swift; sourceTree = "<group>"; };
87012F452797FA97002BCDF0 /* SelfResizableCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelfResizableCollectionView.swift; sourceTree = "<group>"; };
8703F663261319B80082EE25 /* AddressAndRPCServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressAndRPCServer.swift; sourceTree = "<group>"; };
8703F66626135B330082EE25 /* ChartHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartHistory.swift; sourceTree = "<group>"; };
8703F66A26136A500082EE25 /* RenameWalletViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameWalletViewController.swift; sourceTree = "<group>"; };
@ -1863,7 +1862,6 @@
8728FFDE27428FEA008E5524 /* TokenInstanceAttributeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenInstanceAttributeViewModel.swift; sourceTree = "<group>"; };
8728FFE02742900A008E5524 /* SingleTokenCardSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleTokenCardSelectionViewModel.swift; sourceTree = "<group>"; };
8728FFE22742906A008E5524 /* TokenCardTableViewCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCardTableViewCellFactory.swift; sourceTree = "<group>"; };
8728FFE427429966008E5524 /* TokensCardCollectionViewControllerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokensCardCollectionViewControllerViewModel.swift; sourceTree = "<group>"; };
8728FFE92743CAA7008E5524 /* TokenCardSelectionSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCardSelectionSectionHeaderView.swift; sourceTree = "<group>"; };
872D9563271D633D00870971 /* WalletConnectConnectionTimeoutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectConnectionTimeoutViewController.swift; sourceTree = "<group>"; };
872D9565271D66C200870971 /* GasPriceEstimates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GasPriceEstimates.swift; sourceTree = "<group>"; };
@ -1882,7 +1880,6 @@
8739BB5F26CCED010045CFED /* SelectableTokenCardContainerTableViewCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTokenCardContainerTableViewCellViewModel.swift; sourceTree = "<group>"; };
8739BB6526CCED4B0045CFED /* AssetsPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetsPageView.swift; sourceTree = "<group>"; };
8739BB6B26CCED670045CFED /* AssetsPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetsPageViewModel.swift; sourceTree = "<group>"; };
8739BB7226CCEDA10045CFED /* TokensCardCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokensCardCollectionViewController.swift; sourceTree = "<group>"; };
8739BB8E26CCF2F70045CFED /* ActivitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitiesView.swift; sourceTree = "<group>"; };
8739BBC626CD29510045CFED /* TokenCardSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCardSelectionViewController.swift; sourceTree = "<group>"; };
8739BBCC26CD298B0045CFED /* TokenCardSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCardSelectionViewModel.swift; sourceTree = "<group>"; };
@ -1926,6 +1923,8 @@
8759C9EA279BD7C300FE361F /* JsonWalletAddressesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonWalletAddressesStore.swift; sourceTree = "<group>"; };
8759C9EC279E9D6100FE361F /* TextFieldView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = "<group>"; };
875B3C33250A75FA0085BD08 /* QRCodeResolutionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeResolutionCoordinator.swift; sourceTree = "<group>"; };
875F867E27ABC7A60071ABD1 /* NonFungibleTraitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonFungibleTraitViewModel.swift; sourceTree = "<group>"; };
875F868027ABC7C80071ABD1 /* NonFungibleTraitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonFungibleTraitView.swift; sourceTree = "<group>"; };
87620023266E00F60059B05A /* known_contract.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = known_contract.json; sourceTree = "<group>"; };
87620025266E07490059B05A /* ThreadSafeDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeDictionary.swift; sourceTree = "<group>"; };
87620027266E14A80059B05A /* PopularTokenViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularTokenViewCell.swift; sourceTree = "<group>"; };
@ -2030,6 +2029,9 @@
87BC890D26B7EDC6005482F4 /* WalletSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletSummaryView.swift; sourceTree = "<group>"; };
87BC89B726B82287005482F4 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = "<group>"; };
87C10A6425ED1105008E9B1B /* SignatureConfirmationDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignatureConfirmationDetailsViewController.swift; sourceTree = "<group>"; };
87C2008B27A87FD700338D26 /* OpenSeaCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSeaCollection.swift; sourceTree = "<group>"; };
87C2008D27A8802900338D26 /* OpenSeaStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSeaStats.swift; sourceTree = "<group>"; };
87C2009027A8807400338D26 /* OpenSeaAssetDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSeaAssetDecoder.swift; sourceTree = "<group>"; };
87C237DB26DE5303003CA387 /* UIToolbar+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIToolbar+Custom.swift"; sourceTree = "<group>"; };
87C237DD26DE5389003CA387 /* SelectAssetAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectAssetAmountView.swift; sourceTree = "<group>"; };
87C447A9255970C5009DF2D2 /* ActiveWalletSessionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ActiveWalletSessionView.swift; path = AlphaWallet/Tokens/Views/ActiveWalletSessionView.swift; sourceTree = SOURCE_ROOT; };
@ -2479,7 +2481,6 @@
61C359DF2002AA590097B04D /* TransactionSigning.swift */,
29F1C862200375D2003780D8 /* Wallet.swift */,
5E7C7FC30FF22C3EA71451BC /* EthTypedData.swift */,
5E7C7C0CFD047ED7C488FB45 /* OpenSea.swift */,
5E7C7F649A54E4AD0BEA5220 /* Models */,
);
path = EtherClient;
@ -2797,8 +2798,6 @@
5E7C704499C81ACA3B08A752 /* TokenInstanceWebView.swift */,
5E7C78402B975F69A72E8C04 /* TokensViewControllerTableViewHeader.swift */,
5E7C7FB7C3FB2A9CC0CC51D7 /* TokensViewControllerTableViewSectionHeader.swift */,
5E7C7BEDC786FB048A1DD9A8 /* WKWebViewExtension.swift */,
87D1757724ADAEEB002130D2 /* BlockchainTagLabel.swift */,
87620027266E14A80059B05A /* PopularTokenViewCell.swift */,
87620029266E14C10059B05A /* WalletTokenViewCell.swift */,
87F4D41F26C26C6E00EFB9BC /* EmptyFilteringResultView.swift */,
@ -2839,7 +2838,6 @@
5E7C7EE467A7F5F2E5B1F660 /* TokensViewModel.swift */,
5E7C74B82783A94091A43470 /* EthTokenViewCellViewModel.swift */,
5E7C73DF5FBFE756097D32B1 /* EthCurrencyHelper.swift */,
5E7C7C01F8C42D7A43792C26 /* HiddenContract.swift */,
5E7C7D913DAA3322F1C7DD46 /* OpenSeaNonFungibleTokenCardRowViewModel.swift */,
5E7C72A3369125C89C869EE7 /* OpenSea */,
5E7C7AD33AC8BE19F5C66489 /* BaseTokenCardTableViewCellViewModel.swift */,
@ -2850,7 +2848,6 @@
874DED1824C1BD2C006C8FCE /* SelectTokenViewModel.swift */,
8762002B266E150B0059B05A /* WalletTokenViewCellViewModel.swift */,
8762002D266E15310059B05A /* PopularTokenViewCellViewModel.swift */,
5E7C7C85E24A82DBF37E3F76 /* UiTweaks.swift */,
87741FC2277475D2007F4604 /* TokenInstanceInfoPageViewModel.swift */,
);
path = ViewModels;
@ -2868,11 +2865,10 @@
442FCFEB2D7443C4E0B889B0 /* TokenHolder.swift */,
5E7C74B9EB81C51E956566E7 /* TokensDataStore.swift */,
5E7C72BEB789700C49FF64A6 /* DeletedContract.swift */,
5E7C7382EAC8B9CE5EE0668D /* OpenSeaNonFungible.swift */,
5E7C758EEBD945A3451C96C8 /* OpenSeaNonFungibleTokenHandling.swift */,
5E7C7B6FAFE62FBAADB85228 /* Web3Error.swift */,
5E7C72DDBF109139E4C661D5 /* DelegateContract.swift */,
5E7C7C83B57FC8FAE9AF8F26 /* TokenCollection.swift */,
5E7C7C01F8C42D7A43792C26 /* HiddenContract.swift */,
5E7C7957AA1BC0B5BD6E98FF /* TransactionCollection.swift */,
5E7C7B43816C35C3FE2EFFBE /* TokenInstanceAction.swift */,
5E7C74822F5F71748184F6C1 /* EventInstance.swift */,
@ -2939,6 +2935,7 @@
2996F14B1F6CA725005C33AE /* Extensions */ = {
isa = PBXGroup;
children = (
5E7C7BEDC786FB048A1DD9A8 /* WKWebViewExtension.swift */,
2996F14C1F6CA742005C33AE /* UIViewController.swift */,
29EB10291F6CBD23000907A4 /* UIAlertController.swift */,
291ED08A1F6F5D2100E7E93A /* Bundle.swift */,
@ -3344,6 +3341,7 @@
children = (
870CC47727C6560800A19380 /* TokensAutodetector */,
874D2F7C27A2B14C005459DA /* UnstoppableDomains */,
87C2008927A483A100338D26 /* OpenSea */,
87D6E5D3278DBB7400B9DEE3 /* Device.swift */,
87D5BBD52727D68C0053E6D2 /* Enjin */,
874BD2B72669766C00E62E02 /* PopularTokens */,
@ -3530,6 +3528,7 @@
442FCB1A0F74A68425907AC9 /* Helpers */ = {
isa = PBXGroup;
children = (
5E7C7C85E24A82DBF37E3F76 /* UiTweaks.swift */,
442FC20E6470B92A46479342 /* TokenAdaptor.swift */,
5E7C77C2844B3579A59C3F2F /* CallSmartContractFunction.swift */,
87741FC427747C5C007F4604 /* TokenInstanceViewConfigurationHelper.swift */,
@ -3755,7 +3754,7 @@
5E7C7543079DF1C7CA998A2D /* Views */ = {
isa = PBXGroup;
children = (
87012F452797FA97002BCDF0 /* SelfResizableCollectionView.swift */,
87D1757724ADAEEB002130D2 /* BlockchainTagLabel.swift */,
5E7C7CFDE7DEA8C06C4100AF /* TextField.swift */,
5E7C77A8E9F8DF94ED53D452 /* WebImageView.swift */,
87DD0C3F279977E700460260 /* PasswordTextField.swift */,
@ -3997,7 +3996,6 @@
5E7C79F893AFAF493878F3EC /* OpenSea */ = {
isa = PBXGroup;
children = (
87012F432797F9F6002BCDF0 /* OpenSeaAttributeCollectionView.swift */,
5E7C73E57ADDF29E0A5FB87E /* OpenSeaNonFungibleTokenCardRowView.swift */,
5E7C765A9FA64E4CC1B6C726 /* OpenSeaNonFungibleTokenTraitCell.swift */,
5E7C703366F010BFEF6B06C6 /* OpenSeaNonFungibleTokenViewCell.swift */,
@ -4725,6 +4723,7 @@
8728FFDC27428FB5008E5524 /* TokenCardContainerTableViewCell.swift */,
8728FFE92743CAA7008E5524 /* TokenCardSelectionSectionHeaderView.swift */,
872731882778DAD1004EE35D /* ContainerCollectionViewCell.swift */,
875F868027ABC7C80071ABD1 /* NonFungibleTraitView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -4740,7 +4739,7 @@
8728FFD827428F43008E5524 /* NonFungibleRowViewModel.swift */,
8728FFDE27428FEA008E5524 /* TokenInstanceAttributeViewModel.swift */,
8728FFE02742900A008E5524 /* SingleTokenCardSelectionViewModel.swift */,
8728FFE427429966008E5524 /* TokensCardCollectionViewControllerViewModel.swift */,
875F867E27ABC7A60071ABD1 /* NonFungibleTraitViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -4748,7 +4747,6 @@
8739BB7126CCED900045CFED /* ViewControllers */ = {
isa = PBXGroup;
children = (
8739BB7226CCEDA10045CFED /* TokensCardCollectionViewController.swift */,
8739BBC626CD29510045CFED /* TokenCardSelectionViewController.swift */,
879F184526E73CAA000602F2 /* TokenCardListViewController.swift */,
);
@ -5019,6 +5017,35 @@
path = ViewController;
sourceTree = "<group>";
};
87C2008927A483A100338D26 /* OpenSea */ = {
isa = PBXGroup;
children = (
87C2008F27A8806300338D26 /* Helpers */,
87C2008A27A87FBE00338D26 /* Types */,
5E7C7C0CFD047ED7C488FB45 /* OpenSea.swift */,
);
path = OpenSea;
sourceTree = "<group>";
};
87C2008A27A87FBE00338D26 /* Types */ = {
isa = PBXGroup;
children = (
5E7C7382EAC8B9CE5EE0668D /* OpenSeaNonFungible.swift */,
5E7C758EEBD945A3451C96C8 /* OpenSeaNonFungibleTokenHandling.swift */,
87C2008B27A87FD700338D26 /* OpenSeaCollection.swift */,
87C2008D27A8802900338D26 /* OpenSeaStats.swift */,
);
path = Types;
sourceTree = "<group>";
};
87C2008F27A8806300338D26 /* Helpers */ = {
isa = PBXGroup;
children = (
87C2009027A8807400338D26 /* OpenSeaAssetDecoder.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
87C65F512660DD1700919819 /* WalletBalance */ = {
isa = PBXGroup;
children = (
@ -5787,7 +5814,6 @@
87E2555824F52EBF00F025F7 /* GasSpeedTableViewCellViewModel.swift in Sources */,
025F5D202760C76A00B2A3BC /* ExportJsonKeystorePasswordView.swift in Sources */,
296421991F70C1F900EB363B /* EmptyView.swift in Sources */,
8728FFE527429967008E5524 /* TokensCardCollectionViewControllerViewModel.swift in Sources */,
8795994A2604A5B6006722B2 /* CoinTickersFetcher.swift in Sources */,
73C41C71201B46AD00243C6C /* LockEnterPasscodeViewModel.swift in Sources */,
29FA00D0201CA66A002F7DC5 /* DAppError.swift in Sources */,
@ -5795,7 +5821,6 @@
8731E6E02565269400A6A7DA /* Uniswap.swift in Sources */,
7721A6BE202A5677004DB16C /* DecryptError.swift in Sources */,
87BBF9982563DD7600FF4846 /* TransactionConfirmationCoordinatorBridgeToPromise.swift in Sources */,
87012F442797F9F6002BCDF0 /* OpenSeaAttributeCollectionView.swift in Sources */,
61FC5ECF1FCFBAE500CCB12A /* EtherNumberFormatter.swift in Sources */,
87A5D53E26B9767300856995 /* QuickSwap.swift in Sources */,
29DBF2A71F9F145900327C60 /* StateViewModel.swift in Sources */,
@ -5831,6 +5856,7 @@
29FA00CC201CA63C002F7DC5 /* Method.swift in Sources */,
2963A28A1FC402940095447D /* LocalizedOperationObject.swift in Sources */,
29FF12FB1F74CC8200AFD326 /* EthereumAddressValidator.swift in Sources */,
87C2009127A8807400338D26 /* OpenSeaAssetDecoder.swift in Sources */,
87D5BBD92727D9B20053E6D2 /* API.swift in Sources */,
294EC1D81FD7FBAB0065EB20 /* BiometryAuthenticationType.swift in Sources */,
61DCE17B2001A6BE0053939F /* RLP.swift in Sources */,
@ -5905,6 +5931,7 @@
298542F51FBD8E6A00CB5081 /* ConfigExplorer.swift in Sources */,
022CED80277B01E10043287F /* ScrollableSegmentedControlCell.swift in Sources */,
87E2555224F52E5700F025F7 /* SliderTableViewCellViewModel.swift in Sources */,
87C2008C27A87FD700338D26 /* OpenSeaCollection.swift in Sources */,
29DF400A1FD3E80A000077CA /* TabBarController.swift in Sources */,
73C41C73201B5EFF00243C6C /* LockCreatePasscodeViewModel.swift in Sources */,
29B6AED61F7CA4A700EC6DE3 /* TransactionConfiguration.swift in Sources */,
@ -5958,6 +5985,7 @@
87D84747274521FD00EDBA80 /* TransactionConfirmationRPCServerInfoView.swift in Sources */,
291F52BF1F6C874E00B369AB /* AccountsViewController.swift in Sources */,
2963B6AF1F9823E6003063C1 /* UnconfirmedTransaction.swift in Sources */,
87C2008E27A8802900338D26 /* OpenSeaStats.swift in Sources */,
87D5BBD72727D69E0053E6D2 /* EnjinProvider.swift in Sources */,
2996F1431F6C96FF005C33AE /* ImportWalletViewModel.swift in Sources */,
291ED08D1F6F5F0A00E7E93A /* KeyStoreError.swift in Sources */,
@ -6052,13 +6080,13 @@
5E7C774B5332AC0DC19C5B1B /* EthTokenViewCellViewModel.swift in Sources */,
5E7C75D46140FACBD12333BF /* EthTokenViewCell.swift in Sources */,
5E7C728CDF33FBDBA47F71A6 /* MarketplaceViewController.swift in Sources */,
8739BB7326CCEDA10045CFED /* TokensCardCollectionViewController.swift in Sources */,
5E7C7E04D4DDD7D8881A2AB1 /* ImportMagicLinkCoordinator.swift in Sources */,
8795994726049F0F006722B2 /* ActivityStateViewViewModel.swift in Sources */,
5E7C71A6B0BDF301747A49AE /* ScreenChecker.swift in Sources */,
5E7C72AF95DCE8BC65490BCA /* StatusViewController.swift in Sources */,
5E7C783B4784DE76971EEBB4 /* StatusViewControllerViewModel.swift in Sources */,
5E7C776BE1B19F824954962D /* BaseTokenCardTableViewCell.swift in Sources */,
875F868127ABC7C80071ABD1 /* NonFungibleTraitView.swift in Sources */,
5E7C7C0FAC500A6651E663FD /* TransferTokensCardQuantitySelectionViewModel.swift in Sources */,
870CC47F27C657F100A19380 /* TokensAutodetector.swift in Sources */,
5E7C77AD9FAAC18211B6F355 /* TransferTokensCardQuantitySelectionViewController.swift in Sources */,
@ -6315,6 +6343,7 @@
5E7C792AA15C1D3560A18CF8 /* ConsoleCoordinator.swift in Sources */,
5E7C7648BFF9AE93CD97A1BE /* ConsoleViewController.swift in Sources */,
8712A39226F476EF0009C376 /* PriceAlertsPageViewModel.swift in Sources */,
875F867F27ABC7A60071ABD1 /* NonFungibleTraitViewModel.swift in Sources */,
87ED843B24C564B5001A3747 /* NewTokenCoordinator.swift in Sources */,
8712A38326F475830009C376 /* PriceAlert.swift in Sources */,
5E7C73092E428F9519787284 /* Core.swift in Sources */,

@ -86,7 +86,7 @@ class PriceAlertsPageView: UIView, PageViewType {
func reloadData() {
tableView.reloadData()
}
}
}
extension PriceAlertsPageView: UITableViewDataSource {

@ -288,7 +288,7 @@ extension QRCodeResolutionCoordinator: ScanQRCodeCoordinatorDelegate {
}
}
private extension String {
extension String {
var scientificAmountToBigInt: BigInt? {
let numberFormatter = Formatter.scientificAmount
@ -302,10 +302,9 @@ private extension String {
return (try? JSONSerialization.jsonObject(with: jsonData)) != nil
}
static let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
var isValidURL: Bool {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
if let match = detector.firstMatch(in: self, options: [], range: NSRange(location: 0, length: utf16.count)) {
if let match = String.detector.firstMatch(in: self, options: [], range: NSRange(location: 0, length: utf16.count)) {
// it is a link, if the match covers the whole string
return match.range.length == utf16.count
} else {

@ -35,34 +35,32 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}()
weak var erc721TokenIdsFetcher: Erc721TokenIdsFetcher?
private lazy var tokenProvider: TokenProviderType = {
return TokenProvider(account: account, server: server, queue: queue)
}()
private let account: Wallet
private let openSea: OpenSea
let openSea: OpenSea
private let queue: DispatchQueue
private let server: RPCServer
private lazy var etherToken = Activity.AssignedToken(tokenObject: MultipleChainsTokensDataStore.functional.etherToken(forServer: server))
private var isRefeshingBalance: Bool = false
weak var delegate: PrivateTokensDataStoreDelegate?
private let tokensDatastore: TokensDataStore
private let tokensDataStore: TokensDataStore
private let assetDefinitionStore: AssetDefinitionStore
private let enjin: EnjinProvider
private var cachedErc1155TokenIdsFetchers: [AddressAndRPCServer: Erc1155TokenIdsFetcher] = [:]
private var cancelable = Set<AnyCancellable>()
private let keystore: Keystore
init(account: Wallet, tokensDatastore: TokensDataStore, server: RPCServer, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue) {
init(account: Wallet, keystore: Keystore, tokensDataStore: TokensDataStore, server: RPCServer, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue) {
self.keystore = keystore
self.account = account
self.server = server
self.queue = queue
self.openSea = OpenSea.createInstance(with: AddressAndRPCServer(address: account.address, server: server))
self.openSea = OpenSea.createInstance(with: AddressAndRPCServer(address: account.address, server: server), keystore: keystore)
self.enjin = EnjinProvider.createInstance(with: AddressAndRPCServer(address: account.address, server: server))
self.tokensDatastore = tokensDatastore
self.tokensDataStore = tokensDataStore
self.assetDefinitionStore = assetDefinitionStore
// NOTE: fire refresh balance only for initial scope, and while adding new tokens
tokensDatastore
tokensDataStore
.enabledTokenObjectsChangesetPublisher(forServers: [server])
.subscribe(on: DispatchQueue.main)
.sink { [weak self] changeset in
@ -107,8 +105,12 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
}
private func getTokensFromOpenSea() -> OpenSea.PromiseResult {
// TODO when we no longer create multiple instances of MultipleChainsTokensDataStore, we don't have to use singleton for OpenSea class. This was to avoid fetching multiple times from OpenSea concurrently
private func getTokensFromOpenSea() -> Promise<OpenSeaNonFungiblesToAddress> {
// TODO when we no longer create multiple instances of TokensDataStore, we don't have to use singleton for OpenSea class. This was to avoid fetching multiple times from OpenSea concurrently
// NOTE: We need to reduce amount of concurrent calls to Open Sea, because of call trolling of OpenSea, that is why we make calls only for current wallet
guard keystore.currentWallet.address == account.address else {
return .value([:])
}
return openSea.makeFetchPromise()
.recover { _ -> Promise<OpenSeaNonFungiblesToAddress> in
return .value([:])
@ -119,7 +121,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
Promise<[Activity.AssignedToken]> { seal in
DispatchQueue.main.async { [weak self] in
guard let strongSelf = self else { return seal.reject(PMKError.cancelled) }
let tokenObjects = strongSelf.tokensDatastore
let tokenObjects = strongSelf.tokensDataStore
.enabledTokenObjects(forServers: [strongSelf.server])
.map { Activity.AssignedToken(tokenObject: $0) }
@ -178,12 +180,13 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
private func refreshEthBalance(etherToken: Activity.AssignedToken) -> Promise<Bool?> {
let tokensDatastore = self.tokensDatastore
let tokensDataStore = self.tokensDataStore
return tokenProvider.getEthBalance(for: account.address)
return TokenProvider(account: account, server: server, queue: queue)
.getEthBalance(for: account.address)
.then(on: .main, { balance -> Promise<Bool?> in
let result = tokensDatastore.updateToken(primaryKey: etherToken.primaryKey, action: .value(balance.value))
return .value(result)
let tokenHasUpdated = tokensDataStore.updateToken(primaryKey: etherToken.primaryKey, action: .value(balance.value))
return .value(tokenHasUpdated)
}).recover(on: queue, { _ -> Guarantee<Bool?> in
return .value(nil)
})
@ -197,7 +200,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
let promise1 = refreshBalanceForNonErc721Or1155Tokens(tokens: notErc721Or1155Tokens)
let promise2 = refreshBalanceForErc721Or1155Tokens(tokens: erc721Or1155Tokens)
let tokensDatastore = self.tokensDatastore
let tokensDatastore = self.tokensDataStore
return when(resolved: [promise1, promise2])
.then(on: .main, { value -> Promise<Bool?> in
@ -223,32 +226,35 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
private func getBalanceForNonErc721Or1155Tokens(forToken tokenObject: Activity.AssignedToken) -> Promise<TokenBatchOperation?> {
switch tokenObject.type {
case .nativeCryptocurrency:
case .nativeCryptocurrency, .erc721, .erc1155:
return .value(nil)
case .erc20:
return tokenProvider.getERC20Balance(for: tokenObject.contractAddress).map(on: queue, { value -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .value(value))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
return TokenProvider(account: account, server: server, queue: queue)
.getERC20Balance(for: tokenObject.contractAddress)
.map(on: queue, { value -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .value(value))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
case .erc875:
return tokenProvider.getERC875Balance(for: tokenObject.contractAddress).map(on: queue, { balance -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .nonFungibleBalance(balance))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
case .erc721, .erc1155:
return .value(nil)
return TokenProvider(account: account, server: server, queue: queue)
.getERC875Balance(for: tokenObject.contractAddress)
.map(on: queue, { balance -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .nonFungibleBalance(balance))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
case .erc721ForTickets:
return tokenProvider.getERC721ForTicketsBalance(for: tokenObject.contractAddress).map(on: queue, { balance -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .nonFungibleBalance(balance))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
return TokenProvider(account: account, server: server, queue: queue)
.getERC721ForTicketsBalance(for: tokenObject.contractAddress)
.map(on: queue, { balance -> TokenBatchOperation in
return .update(tokenObject: tokenObject, action: .nonFungibleBalance(balance))
}).recover { _ -> Promise<TokenBatchOperation?> in
return .value(nil)
}
}
}
typealias EnjinSemiFungibleTokens = [String: GetEnjinTokenQuery.Data.EnjinToken]
private typealias OpenSeaNonFungiblesToAddress = [AlphaWallet.Address: [OpenSeaNonFungible]]
private func refreshBalanceForErc721Or1155Tokens(tokens: [Activity.AssignedToken]) -> Promise<[PrivateBalanceFetcher.TokenBatchOperation]> {
assert(!tokens.contains { !$0.isERC721Or1155AndNotForTickets })
@ -256,6 +262,8 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
let tokensFromOpenSeaPromise = getTokensFromOpenSea()
let enjinTokensPromise = getTokensFromEnjin()
let queue = queue
let account = account
let server = server
return firstly {
when(fulfilled: tokensFromOpenSeaPromise, enjinTokensPromise)
@ -268,7 +276,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
let erc721Or1155ContractsFoundInOpenSea = Array(contractToOpenSeaNonFungibles.keys).map { $0 }
let erc721Or1155ContractsNotFoundInOpenSea = tokens.map { $0.contractAddress } - erc721Or1155ContractsFoundInOpenSea
let p1 = strongSelf.updateNonOpenSeaNonFungiblesBalance(contracts: erc721Or1155ContractsNotFoundInOpenSea, tokens: tokens, enjinTokens: enjinTokens, queue: queue)
let p2 = strongSelf.updateOpenSeaNonFungiblesBalanceAndAttributes(contractToOpenSeaNonFungibles: contractToOpenSeaNonFungibles, tokens: tokens, enjinTokens: enjinTokens)
let p2 = PrivateBalanceFetcher.functional.updateOpenSeaNonFungiblesBalanceAndAttributes(contractToOpenSeaNonFungibles: contractToOpenSeaNonFungibles, tokens: tokens, enjinTokens: enjinTokens, server: server, account: account)
return when(resolved: [p1, p2]).map(on: queue, { results -> [PrivateBalanceFetcher.TokenBatchOperation] in
return results.compactMap { $0.optionalValue }.flatMap { $0 }
@ -371,7 +379,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
private func addUnknownErc1155ContractsToDatabase(contractsAndTokenIds: Erc1155TokenIds.ContractsAndTokenIds, tokens: [Activity.AssignedToken]) -> Promise<Erc1155TokenIds.ContractsAndTokenIds> {
let tokensDatastore = self.tokensDatastore
let tokensDatastore = self.tokensDataStore
return firstly {
functional.fetchUnknownErc1155ContractsDetails(contractsAndTokenIds: contractsAndTokenIds, tokens: tokens, server: server, account: account, assetDefinitionStore: assetDefinitionStore)
}.then(on: .main, { tokensToAdd -> Promise<Erc1155TokenIds.ContractsAndTokenIds> in
@ -401,26 +409,10 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}.then(on: queue, {
self.fetchTokenJson(forTokenId: tokenId, tokenType: tokenType, uri: $0, address: address, tokens: tokens, enjinTokens: enjinTokens)
}).recover(on: queue, { _ in
return self.generateTokenJsonFallback(forTokenId: tokenId, tokenType: tokenType, address: address, tokens: tokens)
return PrivateBalanceFetcher.functional.generateTokenJsonFallback(forTokenId: tokenId, tokenType: tokenType, address: address, tokens: tokens)
})
}
private func generateTokenJsonFallback(forTokenId tokenId: String, tokenType: TokenType, address: AlphaWallet.Address, tokens: [Activity.AssignedToken]) -> Guarantee<String> {
var jsonDictionary = JSON()
if let tokenObject = tokens.first(where: { $0.contractAddress.sameContract(as: address) }) {
jsonDictionary["tokenId"] = JSON(tokenId)
jsonDictionary["tokenType"] = JSON(tokenType.rawValue)
jsonDictionary["contractName"] = JSON(tokenObject.name)
jsonDictionary["decimals"] = JSON(0)
jsonDictionary["symbol"] = JSON(tokenObject.symbol)
jsonDictionary["name"] = ""
jsonDictionary["imageUrl"] = ""
jsonDictionary["thumbnailUrl"] = ""
jsonDictionary["externalLink"] = ""
}
return .value(jsonDictionary.rawString()!)
}
private func fetchTokenJson(forTokenId tokenId: String, tokenType: TokenType, uri originalUri: URL, address: AlphaWallet.Address, tokens: [Activity.AssignedToken], enjinTokens: EnjinSemiFungibleTokens) -> Promise<String> {
struct Error: Swift.Error {
}
@ -475,12 +467,42 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
}
private func updateOpenSeaNonFungiblesBalanceAndAttributes(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]], tokens: [Activity.AssignedToken], enjinTokens: EnjinSemiFungibleTokens) -> Promise<[TokenBatchOperation]> {
/// For development only
func writeJsonForTransactions(toUrl url: URL) {
guard let transactionStorage = erc721TokenIdsFetcher as? TransactionsStorage else { return }
transactionStorage.writeJsonForTransactions(toUrl: url)
}
}
// swiftlint:enable type_body_length
extension PrivateBalanceFetcher {
class functional {}
}
fileprivate extension PrivateBalanceFetcher.functional {
static func generateTokenJsonFallback(forTokenId tokenId: String, tokenType: TokenType, address: AlphaWallet.Address, tokens: [Activity.AssignedToken]) -> Guarantee<String> {
var jsonDictionary = JSON()
if let tokenObject = tokens.first(where: { $0.contractAddress.sameContract(as: address) }) {
jsonDictionary["tokenId"] = JSON(tokenId)
jsonDictionary["tokenType"] = JSON(tokenType.rawValue)
jsonDictionary["contractName"] = JSON(tokenObject.name)
jsonDictionary["decimals"] = JSON(0)
jsonDictionary["symbol"] = JSON(tokenObject.symbol)
jsonDictionary["name"] = ""
jsonDictionary["imageUrl"] = ""
jsonDictionary["thumbnailUrl"] = ""
jsonDictionary["externalLink"] = ""
}
return .value(jsonDictionary.rawString()!)
}
static func updateOpenSeaNonFungiblesBalanceAndAttributes(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]], tokens: [Activity.AssignedToken], enjinTokens: PrivateBalanceFetcher.EnjinSemiFungibleTokens, server: RPCServer, account: Wallet) -> Promise<[PrivateBalanceFetcher.TokenBatchOperation]> {
var erc1155ContractToOpenSeaNonFungibles = contractToOpenSeaNonFungibles.filter { $0.value.randomElement()?.tokenType == .erc1155 }
//All non-ERC1155 to be defensive
let nonErc1155ContractToOpenSeaNonFungibles = contractToOpenSeaNonFungibles.filter { $0.value.randomElement()?.tokenType != .erc1155 }
func _buildErc1155Updater(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]]) -> Promise<[TokenBatchOperation]> {
func _buildErc1155Updater(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]]) -> Promise<[PrivateBalanceFetcher.TokenBatchOperation]> {
let contractsToTokenIds: [AlphaWallet.Address: [BigInt]] = contractToOpenSeaNonFungibles.mapValues { openSeaNonFungibles -> [BigInt] in
openSeaNonFungibles.compactMap { BigInt($0.tokenId) }
}
@ -493,14 +515,14 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
return firstly {
when(fulfilled: promises)
}.map { (contractsAndBalances: [(contract: AlphaWallet.Address, balances: [BigInt: BigUInt])]) in
functional.fillErc1155NonFungiblesWithBalance(contractToNonFungibles: contractToOpenSeaNonFungibles, contractsAndBalances: contractsAndBalances)
fillErc1155NonFungiblesWithBalance(contractToNonFungibles: contractToOpenSeaNonFungibles, contractsAndBalances: contractsAndBalances)
}.map { contractToOpenSeaNonFungiblesWithUpdatedBalances in
functional.buildUpdateNonFungiblesBalanceActions(contractToNonFungibles: contractToOpenSeaNonFungiblesWithUpdatedBalances, server: self.server, tokens: tokens)
buildUpdateNonFungiblesBalanceActions(contractToNonFungibles: contractToOpenSeaNonFungiblesWithUpdatedBalances, server: server, tokens: tokens)
}
}
func _buildNonErc1155Updater(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]]) -> [TokenBatchOperation] {
functional.buildUpdateNonFungiblesBalanceActions(contractToNonFungibles: contractToOpenSeaNonFungibles, server: server, tokens: tokens)
func _buildNonErc1155Updater(contractToOpenSeaNonFungibles: [AlphaWallet.Address: [OpenSeaNonFungible]]) -> [PrivateBalanceFetcher.TokenBatchOperation] {
buildUpdateNonFungiblesBalanceActions(contractToNonFungibles: contractToOpenSeaNonFungibles, server: server, tokens: tokens)
}
erc1155ContractToOpenSeaNonFungibles = erc1155ContractToOpenSeaNonFungibles.mapValues { element in
@ -520,19 +542,6 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
}
/// For development only
func writeJsonForTransactions(toUrl url: URL) {
guard let transactionStorage = erc721TokenIdsFetcher as? TransactionsStorage else { return }
transactionStorage.writeJsonForTransactions(toUrl: url)
}
}
// swiftlint:enable type_body_length
extension PrivateBalanceFetcher {
class functional {}
}
fileprivate extension PrivateBalanceFetcher.functional {
static func fetchUnknownErc1155ContractsDetails(contractsAndTokenIds: Erc1155TokenIds.ContractsAndTokenIds, tokens: [Activity.AssignedToken], server: RPCServer, account: Wallet, assetDefinitionStore: AssetDefinitionStore) -> Promise<[ERCToken]> {
let contractsToAdd: [AlphaWallet.Address] = contractsAndTokenIds.keys.filter { contract in
!tokens.contains(where: { $0.contractAddress.sameContract(as: contract) })
@ -592,7 +601,7 @@ fileprivate extension PrivateBalanceFetcher.functional {
//no op
}
}
let tokenType: TokenType
if let anyNonFungible = anyNonFungible {
tokenType = anyNonFungible.tokenType.asTokenType

@ -148,7 +148,7 @@ class WalletBalanceCoordinator: NSObject, WalletBalanceCoordinatorType {
}
private func createWalletBalanceFetcher(wallet: Wallet) -> WalletBalanceFetcherType {
let fetcher = WalletBalanceFetcher(wallet: wallet, servers: servers, assetDefinitionStore: assetDefinitionStore, queue: queue, coinTickersFetcher: coinTickersFetcher)
let fetcher = WalletBalanceFetcher(wallet: wallet, keystore: keystore, servers: servers, assetDefinitionStore: assetDefinitionStore, queue: queue, coinTickersFetcher: coinTickersFetcher)
fetcher.delegate = self
return fetcher

@ -42,26 +42,25 @@ class WalletBalanceFetcher: NSObject, WalletBalanceFetcherType {
private let assetDefinitionStore: AssetDefinitionStore
private (set) lazy var subscribableWalletBalance: Subscribable<WalletBalance> = .init(balance)
private var services: ServerDictionary<WalletBalanceFetcherSubServices> = .init()
var tokenObjects: [Activity.AssignedToken] {
//NOTE: replace with more clear solution
tokensDatastore
.enabledTokenObjects(forServers: Array(services.keys))
.map { Activity.AssignedToken(tokenObject: $0) }
}
private let queue: DispatchQueue
private var cache: ThreadSafeDictionary<AddressAndRPCServer, (NotificationToken, Subscribable<BalanceBaseViewModel>)> = .init()
private let coinTickersFetcher: CoinTickersFetcherType
private lazy var tokensDatastore: TokensDataStore = {
return MultipleChainsTokensDataStore(realm: realm, account: wallet, servers: Config().enabledServers)
}()
weak var delegate: WalletBalanceFetcherDelegate?
private let keystore: Keystore
private lazy var realm = Wallet.functional.realm(forAccount: wallet)
required init(wallet: Wallet, servers: [RPCServer], assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue, coinTickersFetcher: CoinTickersFetcherType) {
weak var delegate: WalletBalanceFetcherDelegate?
var tokenObjects: [Activity.AssignedToken] {
tokensDatastore
.enabledTokenObjects(forServers: Array(services.keys))
.map { Activity.AssignedToken(tokenObject: $0) }
}
required init(wallet: Wallet, keystore: Keystore, servers: [RPCServer], assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue, coinTickersFetcher: CoinTickersFetcherType) {
self.wallet = wallet
self.keystore = keystore
self.assetDefinitionStore = assetDefinitionStore
self.queue = queue
self.coinTickersFetcher = coinTickersFetcher
@ -94,7 +93,7 @@ class WalletBalanceFetcher: NSObject, WalletBalanceFetcherType {
private func createServices(wallet: Wallet, server: RPCServer) -> WalletBalanceFetcherSubServices {
let transactionsStorage = TransactionsStorage(realm: realm, server: server, delegate: nil)
let balanceFetcher = PrivateBalanceFetcher(account: wallet, tokensDatastore: tokensDatastore, server: server, assetDefinitionStore: assetDefinitionStore, queue: queue)
let balanceFetcher = PrivateBalanceFetcher(account: wallet, keystore: keystore, tokensDataStore: tokensDatastore, server: server, assetDefinitionStore: assetDefinitionStore, queue: queue)
balanceFetcher.erc721TokenIdsFetcher = transactionsStorage
balanceFetcher.delegate = self

@ -105,7 +105,7 @@ class BlockiesGenerator {
promise
}.then { url -> Promise<BlockiesImage> in
return Self.decodeEip155URL(url: url).then { value -> Promise<BlockiesImage> in
Self.fetchOpenSeaAssetAssetURL(from: value).then { url -> Promise<BlockiesImage> in
Self.fetchOpenSeaAssetImageUrl(from: value).then { url -> Promise<BlockiesImage> in
return Self.fetchEnsAvatar(request: URLRequest(url: url), queue: .main)
}
}.recover { _ -> Promise<BlockiesImage> in
@ -115,8 +115,8 @@ class BlockiesGenerator {
}
}
private static func fetchOpenSeaAssetAssetURL(from value: Eip155URL) -> Promise<URL> {
return OpenSea.fetchAsset(for: value)
private static func fetchOpenSeaAssetImageUrl(from value: Eip155URL) -> Promise<URL> {
return OpenSea.fetchAssetImageUrl(for: value)
}
private static func decodeEip155URL(url: String) -> Promise<Eip155URL> {

@ -0,0 +1,112 @@
//
// Decoder.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 31.01.2022.
//
import Foundation
import SwiftyJSON
import BigInt
struct OpenSeaAssetDecoder {
enum DecoderError: Error {
case jsonInvalidError
case statsDecoding
case erc1155IsNotEnabled
case unsupportedTokenType
case buidUrl
case requestWasThrottled
}
static let dateFormatter: DateFormatter = {
//Expect date string from asset_contract/created_date, etc as: "2020-05-27T16:53:32.834583"
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
return dateFormatter
}()
static func decode(json: JSON, assets: [AlphaWallet.Address: [OpenSeaNonFungible]]) -> [AlphaWallet.Address: [OpenSeaNonFungible]] {
var assets = assets
for (_, each): (String, JSON) in json["assets"] {
let assetContractJson = each["asset_contract"]
let collectionJson = each["collection"]
guard let contract = AlphaWallet.Address(string: assetContractJson["address"].stringValue) else {
continue
}
guard let tokenType = NonFungibleFromJsonTokenType(rawString: assetContractJson["schema_name"].stringValue) else {
continue
}
if !Features.isErc1155Enabled && tokenType == .erc1155 { continue }
let tokenId = each["token_id"].stringValue
let contractName = assetContractJson["name"].stringValue
//So if it's null in OpenSea, we get a 0, as expected. And 0 works for ERC721 too
let decimals = each["decimals"].intValue
let value: BigInt
switch tokenType {
case .erc721:
value = 1
case .erc1155:
//OpenSea API doesn't include value for ERC1155, so we'll have to batch fetch it later for each contract before we update the database
value = 0
}
let symbol = assetContractJson["symbol"].stringValue
let name = each["name"].stringValue
let description = each["description"].stringValue
let thumbnailUrl = each["image_thumbnail_url"].stringValue
//We'll get what seems to be the PNG version first, falling back to the sometimes PNG, but sometimes SVG version
var imageUrl = each["image_preview_url"].stringValue
if imageUrl.isEmpty {
imageUrl = each["image_url"].stringValue
}
let contractImageUrl = assetContractJson["image_url"].stringValue
let externalLink = each["external_link"].stringValue
let backgroundColor = each["background_color"].stringValue
let traits = each["traits"].arrayValue.compactMap { OpenSeaNonFungibleTrait(json: $0) }
let slug = collectionJson["slug"].stringValue
let collectionCreatedDate = assetContractJson["created_date"].string
.flatMap { OpenSeaAssetDecoder.dateFormatter.date(from: $0) }
let collectionDescription = assetContractJson["description"].string
let creator = try? OpenSea.AssetCreator(json: each["creator"])
let cat = OpenSeaNonFungible(tokenId: tokenId, tokenType: tokenType, value: value, contractName: contractName, decimals: decimals, symbol: symbol, name: name, description: description, thumbnailUrl: thumbnailUrl, imageUrl: imageUrl, contractImageUrl: contractImageUrl, externalLink: externalLink, backgroundColor: backgroundColor, traits: traits, collectionCreatedDate: collectionCreatedDate, collectionDescription: collectionDescription, creator: creator, slug: slug)
if var list = assets[contract] {
list.append(cat)
assets[contract] = list
} else {
let list = [cat]
assets[contract] = list
}
}
return assets
}
}
struct OpenSeaCollectionDecoder {
static func decode(json: JSON, results: [OpenSea.CollectionKey: OpenSea.Collection]) -> [OpenSea.CollectionKey: OpenSea.Collection] {
var results = results
for each in json.arrayValue {
guard let collection = try? OpenSea.Collection(json: each) else {
continue
}
if collection.contracts.isEmpty {
results[OpenSea.CollectionKey.slug(collection.slug)] = collection
} else {
for each in collection.contracts {
results[OpenSea.CollectionKey.address(each.address)] = collection
}
}
}
return results
}
}

@ -0,0 +1,277 @@
// Copyright © 2018 Stormbird PTE. LTD.
import Foundation
import Alamofire
import BigInt
import PromiseKit
import Result
import SwiftyJSON
typealias OpenSeaNonFungiblesToAddress = [AlphaWallet.Address: [OpenSeaNonFungible]]
class OpenSea {
private static var statsCache: [String: OpenSea.Stats] = [:]
//Assuming 1 token (token ID, rather than a token) is 4kb, 1500 HyperDragons is 6MB. So we rate limit requests
private static let numberOfTokenIdsBeforeRateLimitingRequests = 25
private static let minimumSecondsBetweenRequests = TimeInterval(60)
private static var instances = [AddressAndRPCServer: WeakRef<OpenSea>]()
//NOTE: using AddressAndRPCServer fixes issue with incorrect tokens returned from makeFetchPromise
// the problem was that cached OpenSea returned tokens from multiple wallets
private let key: AddressAndRPCServer
private var recentWalletsWithManyTokens: [AlphaWallet.Address: (Date, Promise<OpenSeaNonFungiblesToAddress>)] = [:]
private var fetch = OpenSea.makeEmptyFulfilledPromise()
private let queue = DispatchQueue.global(qos: .userInitiated)
private let keystore: Keystore
private init(key: AddressAndRPCServer, keystore: Keystore) {
self.key = key
self.keystore = keystore
}
static func createInstance(with key: AddressAndRPCServer, keystore: Keystore) -> OpenSea {
if let instance = instances[key]?.object {
return instance
} else {
let instance = OpenSea(key: key, keystore: keystore)
instances[key] = WeakRef(object: instance)
return instance
}
}
private static func makeEmptyFulfilledPromise() -> Promise<OpenSeaNonFungiblesToAddress> {
return Promise {
$0.fulfill([:])
}
}
static func isServerSupported(_ server: RPCServer) -> Bool {
switch server {
case .main, .rinkeby:
return true
case .kovan, .ropsten, .poa, .sokol, .classic, .callisto, .custom, .goerli, .xDai, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet, .optimistic, .optimisticKovan, .cronosTestnet, .arbitrum, .arbitrumRinkeby, .palm, .palmTestnet:
return false
}
}
static func resetInstances() {
for each in instances.values {
each.object?.reset()
}
}
///Call this after switching wallets, otherwise when the current promise is fulfilled, the switched to wallet will think the API results are for them
private func reset() {
fetch = OpenSea.makeEmptyFulfilledPromise()
}
///Uses a promise to make sure we don't fetch from OpenSea multiple times concurrently
func makeFetchPromise() -> Promise<OpenSeaNonFungiblesToAddress> {
let owner = key.address
guard OpenSea.isServerSupported(key.server) else {
fetch = .value([:])
return fetch
}
trimCachedPromises()
if let cachedPromise = cachedPromise(forOwner: owner) {
return cachedPromise
}
let queue = queue
if fetch.isResolved {
let offset = 0
//NOTE: some of OpenSea collections have an empty `primary_asset_contracts` array, so we are not able to identifyto each asset connection relates. it solves with `slug` field for collection. We match assets `slug` with collections `slug` values for identification
func findCollection(address: AlphaWallet.Address, asset: OpenSeaNonFungible, collections: [OpenSea.CollectionKey: OpenSea.Collection]) -> OpenSea.Collection? {
return collections[.address(address)] ?? collections[.slug(asset.slug)]
}
//NOTE: Due to OpenSea's policy of sending requests, (we are not able to sent multiple requests, the request trottled, and 1 sec delay is needed)
//to send a new one. First we send fetch assets requests and then fetch collections requests
typealias OpenSeaAssetsAndCollections = ([AlphaWallet.Address: [OpenSeaNonFungible]], [OpenSea.CollectionKey: OpenSea.Collection])
fetch = firstly {
fetchAssetsPage(forOwner: owner, offset: offset)
}.then(on: queue, { assets -> Promise<OpenSeaAssetsAndCollections> in
return self.fetchCollectionsPage(forOwner: owner, offset: offset)
.map({ collections -> OpenSeaAssetsAndCollections in
return (assets, collections)
})
}).map(on: queue, { (assetsExcludingUefa, collections) -> [AlphaWallet.Address: [OpenSeaNonFungible]] in
var result: [AlphaWallet.Address: [OpenSeaNonFungible]] = [:]
for each in assetsExcludingUefa {
let updatedElements = each.value.map { openSeaNonFungible -> OpenSeaNonFungible in
var openSeaNonFungible = openSeaNonFungible
let collection = findCollection(address: each.key, asset: openSeaNonFungible, collections: collections)
openSeaNonFungible.collection = collection
return openSeaNonFungible
}
result[each.key] = updatedElements
}
//NOTE: Not sure if we still need this caching feature, as we retry each failured request
var tokenIdCount = 0
for (_, tokenIds) in assetsExcludingUefa {
tokenIdCount += tokenIds.count
}
self.cachePromise(withTokenIdCount: tokenIdCount, forOwner: owner)
return result
})
}
return fetch
}
private static func getBaseURLForOpensea(for server: RPCServer) -> String {
switch server {
case .main:
return Constants.openseaAPI
case .rinkeby:
return Constants.openseaRinkebyAPI
case .kovan, .ropsten, .poa, .sokol, .classic, .callisto, .xDai, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet, .optimistic, .optimisticKovan, .cronosTestnet, .arbitrum, .arbitrumRinkeby, .palm, .palmTestnet:
return Constants.openseaAPI
}
}
static func fetchAssetImageUrl(for value: Eip155URL) -> Promise<URL> {
let baseURL = getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/asset/\(value.path)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return OpenSea.performOpenSeaRequest(url: url, queue: .main).map { json -> URL in
let image: String = json["image_url"].string ?? json["image_preview_url"].string ?? json["image_thumbnail_url"].string ?? json["image_original_url"].string ?? ""
guard let url = URL(string: image) else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL)"))
}
return url
}
}
private static let sessionManagerWithDefaultHttpHeaders: SessionManager = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 30
return SessionManager(configuration: configuration)
}()
static func collectionStats(slug: String) -> Promise<Stats> {
let baseURL = OpenSea.getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/collection/\(slug)/stats") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return OpenSea.performOpenSeaRequest(url: url, queue: .main).map { json -> Stats in
return try Stats(json: json)
}
}
private func fetchCollectionsPage(forOwner owner: AlphaWallet.Address, offset: Int, sum: [OpenSea.CollectionKey: OpenSea.Collection] = [:]) -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> {
let baseURL = OpenSea.getBaseURLForOpensea(for: key.server)
guard let url = URL(string: "\(baseURL)api/v1/collections?asset_owner=\(owner.eip55String)&limit=300&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return OpenSea.performOpenSeaRequest(url: url, queue: queue)
.then({ [weak self] json -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let results = OpenSeaCollectionDecoder.decode(json: json, results: sum)
let fetchedCount = json.arrayValue.count
if fetchedCount > 0 {
return strongSelf.fetchCollectionsPage(forOwner: owner, offset: offset + fetchedCount, sum: results)
} else {
return .value(sum)
}
}).recover { _ -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> in
//NOTE: return some already fetched amount
return .value(sum)
}
}
private static func performOpenSeaRequest(url: URL, maximumRetryCount: Int = 3, delayMultiplayer: Int = 5, retryDelay: DispatchTimeInterval = .seconds(2), queue: DispatchQueue) -> Promise<JSON> {
struct OpenSeaRequestTrottled: Error {}
func privatePerformOpenSeaRequest(url: URL) -> Promise<(HTTPURLResponse, JSON)> {
return OpenSea.sessionManagerWithDefaultHttpHeaders
.request(url, method: .get, headers: ["X-API-KEY": Constants.Credentials.openseaKey])
.responseJSON(queue: queue, options: .allowFragments)
.map(on: queue, { response -> (HTTPURLResponse, JSON) in
guard let data = response.response.data, let json = try? JSON(data: data), let httpResponse = response.response.response else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(url)"))
}
return (httpResponse, json)
})
}
var delayUpperRangeValueFrom0To: Int = delayMultiplayer
return firstly {
attempt(maximumRetryCount: maximumRetryCount, delayBeforeRetry: retryDelay, delayUpperRangeValueFrom0To: delayUpperRangeValueFrom0To) {
privatePerformOpenSeaRequest(url: url).map { (httpResponse, json) -> (HTTPURLResponse, JSON) in
guard httpResponse.statusCode != 429 else {
delayUpperRangeValueFrom0To += delayMultiplayer
throw OpenSeaRequestTrottled()
}
return (httpResponse, json)
}
}.map { (_, json) -> JSON in
return json
}
}
}
private func fetchAssetsPage(forOwner owner: AlphaWallet.Address, offset: Int, assets: [AlphaWallet.Address: [OpenSeaNonFungible]] = [:]) -> Promise< [AlphaWallet.Address: [OpenSeaNonFungible]]> {
let baseURL = OpenSea.getBaseURLForOpensea(for: key.server)
//Careful to `order_by` with a valid value otherwise OpenSea will return 0 results
guard let url = URL(string: "\(baseURL)api/v1/assets/?owner=\(owner.eip55String)&order_by=pk&order_direction=asc&limit=50&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return OpenSea.performOpenSeaRequest(url: url, queue: queue)
.then({ [weak self] json -> Promise<[AlphaWallet.Address: [OpenSeaNonFungible]]> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let results = OpenSeaAssetDecoder.decode(json: json, assets: assets)
let fetchedCount = json["assets"].count
if fetchedCount > 0 {
return strongSelf.fetchAssetsPage(forOwner: owner, offset: offset + fetchedCount, assets: results)
} else {
//Ignore UEFA from OpenSea, otherwise the token type would be saved wrongly as `.erc721` instead of `.erc721ForTickets`
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(assetsExcludingUefa)
}
}).recover { _ -> Promise< [AlphaWallet.Address: [OpenSeaNonFungible]]> in
//NOTE: return some already fetched amount
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(assetsExcludingUefa)
}
}
private func cachePromise(withTokenIdCount tokenIdCount: Int, forOwner wallet: AlphaWallet.Address) {
guard tokenIdCount >= OpenSea.numberOfTokenIdsBeforeRateLimitingRequests else { return }
recentWalletsWithManyTokens[wallet] = (Date(), fetch)
}
private func cachedPromise(forOwner wallet: AlphaWallet.Address) -> Promise<OpenSeaNonFungiblesToAddress>? {
guard let (_, promise) = recentWalletsWithManyTokens[wallet] else { return nil }
return promise
}
private func trimCachedPromises() {
let cachedWallets = recentWalletsWithManyTokens.keys
let now = Date()
for each in cachedWallets {
guard let (date, _) = recentWalletsWithManyTokens[each] else { continue }
if now.timeIntervalSince(date) >= OpenSea.minimumSecondsBetweenRequests {
recentWalletsWithManyTokens.removeValue(forKey: each)
}
}
}
}

@ -0,0 +1,112 @@
//
// OpenSeaCollection.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 31.01.2022.
//
import Foundation
import SwiftyJSON
extension OpenSea {
enum CollectionKey: Hashable {
case address(AlphaWallet.Address)
case slug(String)
}
struct PrimaryAssetContract: Codable {
let address: AlphaWallet.Address
let assetContractType: String
let createdDate: String
let name: String
let nftVersion: String
let schemaName: String
let symbol: String
let owner: String
let totalSupply: String
let description: String
let externalLink: String
let imageUrl: String
init(json: JSON) throws {
guard let address = AlphaWallet.Address(string: json["address"].stringValue) else {
throw OpenSeaAssetDecoder.DecoderError.jsonInvalidError
}
self.address = address
assetContractType = json["asset_contract_type"].stringValue
createdDate = json["created_date"].stringValue
name = json["name"].stringValue
nftVersion = json["nft_version"].stringValue
schemaName = json["schema_name"].stringValue
symbol = json["symbol"].stringValue
owner = json["owner"].stringValue
totalSupply = json["total_supply"].stringValue
description = json["description"].stringValue
externalLink = json["external_link"].stringValue
imageUrl = json["image_url"].stringValue
}
}
struct Collection: Codable {
let ownedAssetCount: Int
let wikiUrl: String?
let instagramUsername: String?
let twitterUsername: String?
let discordUrl: String?
let telegramUrl: String?
let shortDescription: String?
let bannerImageUrl: String?
let chatUrl: String?
let createdDate: String?
let defaultToFiat: Bool
let descriptionString: String
var stats: Stats?
let name: String
let externalUrl: String?
let slug: String
let contracts: [PrimaryAssetContract]
init(json: JSON) throws {
contracts = json["primary_asset_contracts"].arrayValue.compactMap { json in
return try? PrimaryAssetContract(json: json)
}
slug = json["slug"].stringValue
ownedAssetCount = json["owned_asset_count"].intValue
wikiUrl = json["wiki_url"].stringValue
instagramUsername = json["instagram_username"].string
twitterUsername = json["twitter_username"].string
discordUrl = json["discord_url"].string
telegramUrl = json["telegram_url"].string
shortDescription = json["short_description"].string
bannerImageUrl = json["banner_image_url"].string
chatUrl = json["chat_url"].stringValue
createdDate = json["created_date"].stringValue
defaultToFiat = json["default_to_fiat"].boolValue
descriptionString = json["description"].stringValue
stats = try Stats(json: json)
name = json["name"].stringValue
externalUrl = json["external_url"].string
}
}
struct AssetCreator: Codable {
let contractAddress: AlphaWallet.Address
let config: String
let profileImageUrl: URL?
let user: String?
init(json: JSON) throws {
guard let address = AlphaWallet.Address(string: json["address"].stringValue) else {
throw OpenSeaAssetDecoder.DecoderError.jsonInvalidError
}
self.contractAddress = address
self.config = json["config"].stringValue
self.profileImageUrl = json["profile_img_url"].string.flatMap { URL(string: $0.trimmed) }
self.user = json["user"]["username"].string
}
}
}

@ -42,9 +42,13 @@ struct OpenSeaNonFungible: Codable, NonFungibleFromJson {
var issuer: String?
var created: String?
var transferFee: String?
var collection: OpenSea.Collection?
var creator: OpenSea.AssetCreator?
let slug: String
}
extension OpenSeaNonFungible {
var tokenIdSubstituted: String {
return TokenIdConverter.toTokenIdSubstituted(string: tokenId)
}
@ -80,7 +84,7 @@ extension JSON {
self["nonFungible"] = JSON(enjinToken.nonFungible as Any)
self["blockHeight"] = JSON(enjinToken.blockHeight as Any)
self["mintableSupply"] = JSON(enjinToken.mintableSupply as Any)
self["issuer"] = JSON(enjinToken.creator as Any)
self["enjin.issuer"] = JSON(enjinToken.creator as Any)
self["created"] = JSON(enjinToken.createdAt as Any)
self["transferFee"] = JSON(enjinToken.transferFeeSettings?.type?.rawValue as Any)
}
@ -105,6 +109,12 @@ struct OpenSeaNonFungibleTrait: Codable {
let count: Int
let type: String
let value: String
init(json: JSON) {
count = json["trait_count"].intValue
type = json["trait_type"].stringValue
value = json["value"].stringValue
}
}
struct OpenSeaError: Error {
@ -132,7 +142,7 @@ struct OpenSeaNonFungibleBeforeErc1155Support: Codable {
}
func asPostErc1155Support(tokenType: NonFungibleFromJsonTokenType?) -> NonFungibleFromJson {
let result = OpenSeaNonFungible(tokenId: tokenId, tokenType: tokenType ?? .erc721, value: 1, contractName: contractName, decimals: 0, symbol: symbol, name: name, description: description, thumbnailUrl: thumbnailUrl, imageUrl: imageUrl, contractImageUrl: contractImageUrl, externalLink: externalLink, backgroundColor: backgroundColor, traits: traits, collectionCreatedDate: nil, collectionDescription: nil)
let result = OpenSeaNonFungible(tokenId: tokenId, tokenType: tokenType ?? .erc721, value: 1, contractName: contractName, decimals: 0, symbol: symbol, name: name, description: description, thumbnailUrl: thumbnailUrl, imageUrl: imageUrl, contractImageUrl: contractImageUrl, externalLink: externalLink, backgroundColor: backgroundColor, traits: traits, collectionCreatedDate: nil, collectionDescription: nil, creator: nil, slug: "")
return result
}
}

@ -0,0 +1,68 @@
//
// OpenSeaStats.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 31.01.2022.
//
import Foundation
import SwiftyJSON
extension OpenSea {
struct Stats: Codable {
let oneDayVolume: Double
let oneDayChange: Double
let oneDaySales: Double
let oneDayAveragePrice: Double
let sevenDayVolume: Double
let sevenDayChange: Double
let sevenDaySales: Double
let sevenDayAveragePrice: Double
let thirtyDayVolume: Double
let thirtyDayChange: Double
let thirtyDaySales: Double
let thirtyDayAveragePrice: Double
let itemsCount: Double
let totalVolume: Double
let totalSales: Double
let totalSupply: Double
let owners: Int
let averagePrice: Double
let marketCap: Double
let floorPrice: Double?
let numReports: Int
init(json: JSON) throws {
guard json["stats"] != .null else {
throw OpenSeaAssetDecoder.DecoderError.statsDecoding
}
let json = json["stats"]
oneDayVolume = json["one_day_volume"].doubleValue
oneDayChange = json["one_day_change"].doubleValue
oneDaySales = json["one_day_sales"].doubleValue
oneDayAveragePrice = json["one_day_average_price"].doubleValue
sevenDayVolume = json["seven_day_volume"].doubleValue
sevenDayChange = json["seven_day_change"].doubleValue
sevenDaySales = json["seven_day_sales"].doubleValue
sevenDayAveragePrice = json["seven_day_average_price"].doubleValue
thirtyDayVolume = json["thirty_day_volume"].doubleValue
thirtyDayChange = json["thirty_day_change"].doubleValue
thirtyDaySales = json["thirty_day_sales"].doubleValue
thirtyDayAveragePrice = json["thirty_day_average_price"].doubleValue
itemsCount = json["count"].doubleValue
totalVolume = json["total_volume"].doubleValue
totalSales = json["total_sales"].doubleValue
totalSupply = json["total_supply"].doubleValue
owners = json["num_owners"].intValue
averagePrice = json["average_price"].doubleValue
marketCap = json["market_cap"].doubleValue
floorPrice = json["floor_price"].double
numReports = json["num_reports"].intValue
}
}
}

@ -23,7 +23,6 @@ protocol TokenProviderType: class {
}
class TokenProvider: TokenProviderType {
static let fetchContractDataTimeout = TimeInterval(4)
private let account: Wallet
private let numberOfTimesToRetryFetchContractData = 2
private let server: RPCServer

@ -1,39 +0,0 @@
//
// SelfResizableCollectionView.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 18.01.2022.
//
import UIKit
class SelfResizableCollectionView: UICollectionView {
private var heightConstant: NSLayoutConstraint!
private var contentSizeObservation: NSKeyValueObservation!
override init(frame: CGRect, collectionViewLayout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: collectionViewLayout)
translatesAutoresizingMaskIntoConstraints = false
heightConstant = heightAnchor.constraint(greaterThanOrEqualToConstant: 1)
heightConstant.priority = UILayoutPriority(999)
heightConstant.isActive = true
setContentHuggingPriority(.required, for: .vertical)
contentSizeObservation = observe(\.contentSize, options: [.initial, .new]) { [weak self] cv, _ in
guard let strongSelf = self, cv.contentSize != .zero else { return }
strongSelf.heightConstant.constant = cv.contentSize.height
}
}
override func reloadData() {
super.reloadData()
collectionViewLayout.invalidateLayout()
}
required init?(coder: NSCoder) {
return nil
}
}

@ -46,6 +46,16 @@ struct Formatter {
return formatter
}()
static func shortCrypto(symbol: String) -> NumberFormatter {
let formatter = basicCurrencyFormatter()
formatter.positiveFormat = ",###.#" + " " + symbol
formatter.negativeFormat = "-,###.#" + " " + symbol
formatter.minimumFractionDigits = Constants.etherFormatterFractionDigits
formatter.maximumFractionDigits = Constants.etherFormatterFractionDigits
formatter.numberStyle = .none
return formatter
}
static let priceChange: NumberFormatter = {
let formatter = basicCurrencyFormatter()
formatter.positiveFormat = "+$,###.#"

@ -59,7 +59,7 @@ final class StringFormatter {
}
}
func largeNumberFormatter(for double: Double, currency: String) -> String {
func largeNumberFormatter(for double: Double, currency: String, decimals: Int = 1) -> String {
let suffix = ["", "K", "M", "B", "T", "P", "E"]
func formatNumber(_ number: Double) -> (value: Double, suffix: String) {
@ -73,6 +73,6 @@ final class StringFormatter {
}
let result = formatNumber(double)
return String(format: "%.1f%@ %@", result.value, result.suffix, currency)
return String(format: "%.\(decimals)f%@ %@", result.value, result.suffix, currency)
}
}

@ -722,13 +722,27 @@
"semifungibles.assetsCount" = "%d Assets";
"semifungibles.details" = "Details";
"semifungibles.description" = "Description";
"semifungibles.attributes" = "Attributes";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Properties";
"semifungibles.rankings" = "Rankings";
"semifungibles.stats" = "Stats";
"semifungibles.selectedTokens" = "Selected Tokens";
"semifungibles.createdDate" = "Created";
"semifungibles.value" = "Value";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (Customer Support)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (File an issue)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "No active WalletConnect sessions";
"walletConnect.sessions.scanQrCode" = "Scan QR Code";
"priceAlert.emptyList" = "Alerts will appear here";

@ -722,13 +722,27 @@
"semifungibles.assetsCount" = "%d Assets";
"semifungibles.details" = "Details";
"semifungibles.description" = "Description";
"semifungibles.attributes" = "Attributes";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Properties";
"semifungibles.rankings" = "Rankings";
"semifungibles.stats" = "Stats";
"semifungibles.selectedTokens" = "Selected Tokens";
"semifungibles.createdDate" = "Created";
"semifungibles.value" = "Value";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (Customer Support)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (File an issue)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "No active WalletConnect sessions";
"walletConnect.sessions.scanQrCode" = "Scan QR Code";
"priceAlert.emptyList" = "Alerts will appear here";

@ -722,6 +722,7 @@
"semifungibles.assetsCount" = "%d omaisuuserää";
"semifungibles.details" = "Yksityiskohdat";
"semifungibles.description" = "Kuvaus";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Ominaisuudet";
"semifungibles.rankings" = "Sijoitukset";
"semifungibles.stats" = "Tilastot";
@ -729,6 +730,19 @@
"semifungibles.createdDate" = "Luontipäivä";
"semifungibles.value" = "Arvo";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (tuotetuki)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (raportoi ohjelmistovirheestä)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "WalletConnect-kytkentöjä ei ole yhtään käytössä";
"walletConnect.sessions.scanQrCode" = "Lue QR-koodi";
"priceAlert.emptyList" = "Hälytykset löytyvät täältä";

@ -722,13 +722,27 @@
"semifungibles.assetsCount" = "%d Assets";
"semifungibles.details" = "Details";
"semifungibles.description" = "Description";
"semifungibles.attributes" = "Attributes";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Properties";
"semifungibles.rankings" = "Rankings";
"semifungibles.stats" = "Stats";
"semifungibles.selectedTokens" = "Selected Tokens";
"semifungibles.createdDate" = "Created";
"semifungibles.value" = "Value";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (Customer Support)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (File an issue)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "No active WalletConnect sessions";
"walletConnect.sessions.scanQrCode" = "Scan QR Code";
"priceAlert.emptyList" = "Alerts will appear here";

@ -722,13 +722,27 @@
"semifungibles.assetsCount" = "%d Assets";
"semifungibles.details" = "Details";
"semifungibles.description" = "Description";
"semifungibles.attributes" = "Attributes";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Properties";
"semifungibles.rankings" = "Rankings";
"semifungibles.stats" = "Stats";
"semifungibles.selectedTokens" = "Selected Tokens";
"semifungibles.createdDate" = "Created";
"semifungibles.value" = "Value";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (Customer Support)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (File an issue)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "No active WalletConnect sessions";
"walletConnect.sessions.scanQrCode" = "Scan QR Code";
"priceAlert.emptyList" = "Alerts will appear here";

@ -722,13 +722,27 @@
"semifungibles.assetsCount" = "%d Assets";
"semifungibles.details" = "Details";
"semifungibles.description" = "Description";
"semifungibles.attributes" = "Attributes";
"semifungibles.links" = "Links";
"semifungibles.attributes" = "Properties";
"semifungibles.rankings" = "Rankings";
"semifungibles.stats" = "Stats";
"semifungibles.selectedTokens" = "Selected Tokens";
"semifungibles.createdDate" = "Created";
"semifungibles.value" = "Value";
"semifungibles.tokenId" = "Token ID";
"nonfungibles.value.numReports" = "Num Reports";
"nonfungibles.value.floorPrice" = "Floor Price";
"nonfungibles.value.marketCap" = "Market Cap";
"nonfungibles.value.averagePrice" = "Average Price";
"nonfungibles.value.owners" = "Owners";
"nonfungibles.value.totalSupply" = "Total Supply";
"nonfungibles.value.totalSales" = "Total Sales";
"nonfungibles.value.totalVolume" = "Total Volume";
"nonfungibles.value.itemsCount" = "Items";
"nonfungibles.value.ownedAssetCount" = "Owned Asset Count";
"nonfungibles.value.blockchain" = "Blockchain";
"nonfungibles.value.tokenStandard" = "Token Standard";
"nonfungibles.value.contract" = "Contract";
"url.discord" = "Discord";
"url.telegramCustomer" = "Telegram (Customer Support)";
"url.twitter" = "Twitter";
@ -738,6 +752,19 @@
"url.github" = "Github (File an issue)";
"support.blog.title" = "Blog";
"support.email.title" = "Email";
"wiki" = "Wiki";
"instagram" = "Instagram";
"twitter" = "Twitter";
"discord" = "Discord";
"telegram" = "Telegram";
"website" = "Website";
"openOnInstagram" = "View on Instagram";
"openOnTwitter" = "View on Twitter";
"openOnDiscord" = "View on Discord";
"openOnTelegram" = "View on Telegram";
"openOnOpenSea" = "View in OpenSea";
"visitWebsite" = "Visit website";
"visitWiki" = "Visit Wiki";
"walletConnect.sessions.empty" = "No active WalletConnect sessions";
"walletConnect.sessions.scanQrCode" = "Scan QR Code";
"priceAlert.emptyList" = "Alerts will appear here";

@ -5,27 +5,25 @@ import Foundation
struct ConfigExplorer {
private let server: RPCServer
init(
server: RPCServer
) {
init(server: RPCServer) {
self.server = server
}
func transactionURL(for ID: String) -> (url: URL, name: String)? {
let result = explorer(for: server)
guard let endpoint = result.url else { return .none }
let urlString: String? = {
switch server {
case .main, .kovan, .ropsten, .rinkeby, .sokol, .classic, .xDai, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet, .optimistic, .optimisticKovan, .callisto, .poa, .cronosTestnet, .custom, .arbitrum, .arbitrumRinkeby, .palm, .palmTestnet:
return endpoint + "/tx/" + ID
}
}()
guard let string = urlString, let url = URL(string: string) else { return .none }
func transactionUrl(for ID: String) -> (url: URL, name: String)? {
let result = ConfigExplorer.explorer(for: server)
return result.url
.flatMap { URL(string: $0 + "/tx/" + ID) }
.flatMap { (url: $0, name: result.name) }
}
return (url: url, name: result.name)
func contractUrl(address: AlphaWallet.Address) -> (url: URL, name: String)? {
let result = ConfigExplorer.explorer(for: server)
return result.url
.flatMap { URL(string: $0 + "/address/" + address.eip55String) }
.flatMap { (url: $0, name: result.name) }
}
func explorerName(for server: RPCServer) -> String {
private static func explorerName(for server: RPCServer) -> String {
switch server {
case .main, .kovan, .ropsten, .rinkeby, .goerli:
return "Etherscan"
@ -38,7 +36,7 @@ struct ConfigExplorer {
}
}
private func explorer(for server: RPCServer) -> (url: String?, name: String) {
private static func explorer(for server: RPCServer) -> (url: String?, name: String) {
let nameForServer = explorerName(for: server)
let url = server.etherscanWebpageRoot
return (url?.absoluteString, nameForServer)

@ -88,6 +88,59 @@ enum URLServiceProvider {
}
}
enum SocialNetworkUrlProvider {
case discord
case telegram
case twitter
case facebook
case instagram
static func resolveUrl(for user: String, urlProvider: SocialNetworkUrlProvider) -> URL? {
if let url = URL(string: user), user.isValidURL {
return url
}
guard let deepLink = urlProvider.deepLinkURL(user: user), UIApplication.shared.canOpenURL(deepLink) else {
if let url = urlProvider.remoteURL(user: user) {
return url
} else {
return URL(string: user)
}
}
return deepLink
}
func deepLinkURL(user: String) -> URL? {
switch self {
case .discord:
return URL(string: "https://discord.com/\(user)")
case .telegram:
return URL(string: "https://t.me/\(user)")
case .twitter:
return URL(string: "twitter://user?screen_name=\(user)")
case .facebook:
return URL(string: "https://www.facebook.com/\(user)")
case .instagram:
return URL(string: "instagram://user?username=\(user)")
}
}
func remoteURL(user: String) -> URL? {
switch self {
case .discord:
return URL(string: "https://discord.com/\(user)")
case .telegram:
return URL(string: "https://t.me/\(user)")
case .twitter:
return URL(string: "https://twitter.com/\(user)")
case .facebook:
return URL(string: "https://www.facebook.com/\(user)")
case .instagram:
return URL(string: "https://instagram.com/\(user)")
}
}
}
import MessageUI
final class ContactUsEmailResolver: NSObject {

@ -1,7 +1,7 @@
// Copyright © 2018 Stormbird PTE. LTD.
import Foundation
import BigInt
import BigInt
struct AssetAttributeSyntaxValue: Hashable {
static func == (lhs: AssetAttributeSyntaxValue, rhs: AssetAttributeSyntaxValue) -> Bool {
@ -325,8 +325,12 @@ extension Dictionary where Key == AttributeId, Value == AssetAttributeSyntaxValu
self["supplyModel"]?.stringValue
}
var issuer: String? {
return self["issuer"]?.stringValue
var enjinIssuer: String? {
let rawValue = self["enjin.issuer"]?.stringValue
guard let maybeAddress = rawValue.flatMap({ AlphaWallet.Address(string: $0) }) else {
return rawValue
}
return maybeAddress.truncateMiddle
}
var created: String? {
@ -337,14 +341,32 @@ extension Dictionary where Key == AttributeId, Value == AssetAttributeSyntaxValu
return self["transferFee"]?.stringValue
}
mutating func setTraits(value: [OpenSeaNonFungibleTrait]) {
self["traits"] = .init(openSeaTraits: value)
}
var descriptionAssetInternalValue: AssetInternalValue? {
self["description"]?.value
}
var slug: String? {
self["slug"]?.stringValue
}
var collectionValue: OpenSea.Collection? {
return self["collection"]?.stringValue.flatMap { rawValue -> OpenSea.Collection? in
guard let data = rawValue.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OpenSea.Collection.self, from: data)
}
}
var creatorValue: OpenSea.AssetCreator? {
return self["creator"]?.stringValue.flatMap { rawValue -> OpenSea.AssetCreator? in
guard let data = rawValue.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OpenSea.AssetCreator.self, from: data)
}
}
mutating func setTraits(value: [OpenSeaNonFungibleTrait]) {
self["traits"] = .init(openSeaTraits: value)
}
mutating func setDecimals(int: Int) {
self["decimals"] = .init(int: BigInt(int))
}
@ -398,7 +420,7 @@ extension Dictionary where Key == AttributeId, Value == AssetAttributeSyntaxValu
}
mutating func setIssuer(string: String?) {
self["issuer"] = string.flatMap { .init(directoryString: $0) }
self["enjin.issuer"] = string.flatMap { .init(directoryString: $0) }
}
mutating func setCreated(string: String?) {
@ -408,4 +430,26 @@ extension Dictionary where Key == AttributeId, Value == AssetAttributeSyntaxValu
mutating func setTransferFee(string: String?) {
self["transferFee"] = string.flatMap { .init(directoryString: $0) }
}
mutating func setCollection(collection: OpenSea.Collection?) {
self["collection"] = collection.flatMap { collection -> String? in
let data = try? JSONEncoder().encode(collection)
return data.flatMap { data in
String(data: data, encoding: .utf8)
}
}.flatMap { .init(directoryString: $0) }
}
mutating func setCreator(creator: OpenSea.AssetCreator?) {
self["creator"] = creator.flatMap { creator -> String? in
let data = try? JSONEncoder().encode(creator)
return data.flatMap { data in
String(data: data, encoding: .utf8)
}
}.flatMap { .init(directoryString: $0) }
}
mutating func setSlug(string: String?) {
self["slug"] = string.flatMap { .init(directoryString: $0) }
}
}

@ -7,7 +7,6 @@
import Foundation
import UIKit
import Result
import SafariServices
import MessageUI
import BigInt
@ -23,7 +22,7 @@ protocol TokensCardCollectionCoordinatorDelegate: class, CanOpenURL {
class TokensCardCollectionCoordinator: NSObject, Coordinator {
private let keystore: Keystore
private let token: TokenObject
private (set) lazy var rootViewController: TokensCardCollectionViewController = {
private (set) lazy var rootViewController: TokensCardViewController = {
return makeTokensCardCollectionViewController()
}()
@ -69,7 +68,7 @@ class TokensCardCollectionCoordinator: NSObject, Coordinator {
}
func start() {
let viewModel = TokensCardCollectionViewControllerViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
let viewModel = TokensCardViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
rootViewController.configure(viewModel: viewModel)
navigationController.pushViewController(rootViewController, animated: true)
refreshUponAssetDefinitionChanges()
@ -102,8 +101,8 @@ class TokensCardCollectionCoordinator: NSObject, Coordinator {
for each in navigationController.viewControllers {
switch each {
case let vc as TokensCardCollectionViewController:
let viewModel = TokensCardCollectionViewControllerViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
case let vc as TokensCardViewController:
let viewModel = TokensCardViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
vc.configure(viewModel: viewModel)
case let vc as TokenInstanceViewController:
let updatedTokenHolders = TokenAdaptor(token: token, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: session.account)
@ -121,12 +120,12 @@ class TokensCardCollectionCoordinator: NSObject, Coordinator {
return .filter(transactionsStorage: transactionsStorage, strategy: strategy, tokenObject: tokenObject)
}
private func makeTokensCardCollectionViewController() -> TokensCardCollectionViewController {
let viewModel = TokensCardCollectionViewControllerViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
private func makeTokensCardCollectionViewController() -> TokensCardViewController {
let viewModel = TokensCardViewModel(token: token, forWallet: session.account, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore)
let activitiesFilterStrategy: ActivitiesFilterStrategy = .operationTypes(operationTypes: [.erc1155TokenTransfer], contract: token.contractAddress)
let activitiesService = self.activitiesService.copy(activitiesFilterStrategy: activitiesFilterStrategy, transactionsFilterStrategy: transactionsFilter(for: activitiesFilterStrategy, tokenObject: token))
let controller = TokensCardCollectionViewController(session: session, tokensDataStore: tokensStorage, assetDefinition: assetDefinitionStore, analyticsCoordinator: analyticsCoordinator, token: token, viewModel: viewModel, activitiesService: activitiesService, eventsDataStore: eventsDataStore)
let controller = TokensCardViewController(session: session, assetDefinition: assetDefinitionStore, analyticsCoordinator: analyticsCoordinator, token: token, viewModel: viewModel, activitiesService: activitiesService, eventsDataStore: eventsDataStore)
controller.hidesBottomBarWhenPushed = true
controller.delegate = self
controller.navigationItem.leftBarButtonItem = .backBarButton(self, selector: #selector(didCloseSelected))
@ -159,21 +158,17 @@ class TokensCardCollectionCoordinator: NSObject, Coordinator {
}
extension TokensCardCollectionCoordinator: TokensCardCollectionViewControllerDelegate {
extension TokensCardCollectionCoordinator: TokensCardViewControllerDelegate {
func didPressTransfer(token: TokenObject, tokenHolder: TokenHolder, forPaymentFlow paymentFlow: PaymentFlow, in viewController: TokensCardCollectionViewController) {
delegate?.didTap(for: paymentFlow, in: self, viewController: viewController)
}
func didTap(action: TokenInstanceAction, tokenHolder: TokenHolder, viewController: TokensCardCollectionViewController) {
showTokenInstanceActionView(forAction: action, tokenHolder: tokenHolder, viewController: viewController)
func didCancel(in viewController: TokensCardViewController) {
delegate?.didClose(in: self)
}
private func showTokenInstanceActionView(forAction action: TokenInstanceAction, tokenHolder: TokenHolder, viewController: UIViewController) {
delegate?.didTap(for: .send(type: .tokenScript(action: action, tokenObject: token, tokenHolder: tokenHolder)), in: self, viewController: viewController)
}
func didSelectTokenHolder(in viewController: TokensCardCollectionViewController, didSelectTokenHolder tokenHolder: TokenHolder) {
func didSelectTokenHolder(in viewController: TokensCardViewController, didSelectTokenHolder tokenHolder: TokenHolder) {
switch tokenHolder.type {
case .collectible:
let viewModel = TokenCardListViewControllerViewModel(tokenHolder: tokenHolder)
@ -186,15 +181,15 @@ extension TokensCardCollectionCoordinator: TokensCardCollectionViewControllerDel
}
}
func didTap(transaction: TransactionInstance, in viewController: TokensCardCollectionViewController) {
func didTap(transaction: TransactionInstance, in viewController: TokensCardViewController) {
delegate?.didTap(transaction: transaction, in: self)
}
func didTap(activity: Activity, in viewController: TokensCardCollectionViewController) {
func didTap(activity: Activity, in viewController: TokensCardViewController) {
delegate?.didTap(activity: activity, in: self)
}
func didSelectAssetSelection(in viewController: TokensCardCollectionViewController) {
func didSelectAssetSelection(in viewController: TokensCardViewController) {
showTokenCardSelection(tokenHolders: viewController.viewModel.tokenHolders)
}

@ -0,0 +1,50 @@
//
// NonFungibleTraitViewModel.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 03.02.2022.
//
import UIKit
struct NonFungibleTraitViewModel: Equatable {
static func == (lsh: NonFungibleTraitViewModel, rhs: NonFungibleTraitViewModel) -> Bool {
return lsh.title == rhs.title &&
lsh.attributedValue == rhs.attributedValue &&
lsh.separatorColor == rhs.separatorColor &&
lsh.isSeparatorHidden == rhs.isSeparatorHidden &&
lsh.count == rhs.count &&
lsh.attributedCountValue == rhs.attributedCountValue
}
private let title: String?
let value: String?
let count: String?
var attributedValue: NSAttributedString?
var attributedCountValue: NSAttributedString?
var separatorColor: UIColor = R.color.mercury()!
var isSeparatorHidden: Bool = false
init(title: String?, attributedValue: NSAttributedString?, attributedCountValue: NSAttributedString?, isSeparatorHidden: Bool = false) {
self.title = title
self.attributedValue = attributedValue
self.attributedCountValue = attributedCountValue
self.isSeparatorHidden = isSeparatorHidden
self.value = attributedValue?.string
self.count = attributedCountValue?.string
}
init(title: String?, attributedValue: NSAttributedString?, attributedCountValue: NSAttributedString?, value: String?, count: String?, isSeparatorHidden: Bool = false) {
self.title = title
self.attributedValue = attributedValue
self.attributedCountValue = attributedCountValue
self.isSeparatorHidden = isSeparatorHidden
self.value = value
self.count = count
}
var attributedTitle: NSAttributedString? {
title.flatMap { TokenInstanceAttributeViewModel.defaultTitleAttributedString($0) }
}
}

@ -17,39 +17,51 @@ struct TokenInstanceAttributeViewModel: Equatable {
}
private let title: String?
var value: String? {
attributedValue?.string
}
let value: String?
var attributedValue: NSAttributedString?
var separatorColor: UIColor = R.color.mercury()!
var isSeparatorHidden: Bool = false
var valueLabelNumberOfLines: Int = 0
init(title: String?, attributedValue: NSAttributedString?, isSeparatorHidden: Bool = false) {
self.title = title
self.attributedValue = attributedValue
self.isSeparatorHidden = isSeparatorHidden
self.value = attributedValue?.string
}
init(title: String?, attributedValue: NSAttributedString?, value: String?, isSeparatorHidden: Bool = false) {
self.title = title
self.attributedValue = attributedValue
self.isSeparatorHidden = isSeparatorHidden
self.value = value
}
var attributedTitle: NSAttributedString? {
title.flatMap { Self.defaultTitleAttributedString($0) }
}
static func defaultTitleAttributedString(_ value: String, alignment: NSTextAlignment = .left) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Fonts.regular(size: 15), foregroundColor: R.color.dove()!)
static func defaultTitleAttributedString(_ value: String, alignment: NSTextAlignment = .left, lineBreakMode: NSLineBreakMode = .byTruncatingTail) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Fonts.regular(size: 15), foregroundColor: R.color.dove()!, lineBreakMode: lineBreakMode)
}
static func defaultValueAttributedString(_ value: String, alignment: NSTextAlignment = .right, lineBreakMode: NSLineBreakMode = .byTruncatingTail) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Fonts.regular(size: 17), foregroundColor: Colors.black, lineBreakMode: lineBreakMode)
}
static func defaultValueAttributedString(_ value: String, alignment: NSTextAlignment = .right) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Fonts.regular(size: 17), foregroundColor: Colors.black)
static func urlValueAttributedString(_ value: String, alignment: NSTextAlignment = .right, lineBreakMode: NSLineBreakMode = .byTruncatingTail) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Fonts.semibold(size: 17), foregroundColor: Colors.appTint, lineBreakMode: lineBreakMode)
}
static func boldValueAttributedString(_ value: String, alignment: NSTextAlignment = .right) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Screen.TokenCard.Font.valueChangeValue, foregroundColor: Colors.black)
static func boldValueAttributedString(_ value: String, alignment: NSTextAlignment = .right, lineBreakMode: NSLineBreakMode = .byTruncatingTail) -> NSAttributedString {
attributedString(value, alignment: alignment, font: Screen.TokenCard.Font.valueChangeValue, foregroundColor: Colors.black, lineBreakMode: lineBreakMode)
}
private static func attributedString(_ value: String, alignment: NSTextAlignment, font: UIFont, foregroundColor: UIColor) -> NSAttributedString {
private static func attributedString(_ value: String, alignment: NSTextAlignment, font: UIFont, foregroundColor: UIColor, lineBreakMode: NSLineBreakMode) -> NSAttributedString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
paragraphStyle.lineBreakMode = lineBreakMode
return .init(string: value, attributes: [
.font: font,
.foregroundColor: foregroundColor,

@ -14,7 +14,6 @@ enum TokensCardCollectionInfoPageViewConfiguration {
}
struct TokensCardCollectionInfoPageViewModel {
var tabTitle: String {
return R.string.localizable.tokenTabInfo()
}
@ -26,31 +25,63 @@ struct TokensCardCollectionInfoPageViewModel {
tokenObject.contractAddress
}
let tokenHolders: [TokenHolder]
var configurations: [TokensCardCollectionInfoPageViewConfiguration] = []
var tokenImagePlaceholder: UIImage? {
return R.image.tokenPlaceholderLarge()
}
private let tokenHolderHelper: TokenInstanceViewConfigurationHelper
var openInUrl: URL? {
let values = tokenHolders[0].values
return values.collectionValue.flatMap { collection -> URL? in
guard collection.slug.trimmed.nonEmpty else { return nil }
return URL(string: "https://opensea.io/collection/\(collection.slug)")
}
}
var wikiUrl: URL? {
tokenHolderHelper.wikiUrlViewModel?.value.flatMap {
URL(string: $0)
}
}
var instagramUrl: URL? {
tokenHolderHelper.instagramUsernameViewModel?.value
.flatMap { SocialNetworkUrlProvider.resolveUrl(for: $0, urlProvider: .instagram) }
}
var twitterUrl: URL? {
tokenHolderHelper.twitterUsernameViewModel?.value
.flatMap { SocialNetworkUrlProvider.resolveUrl(for: $0, urlProvider: .twitter) }
}
var discordUrl: URL? {
tokenHolderHelper.discordUrlViewModel?.value
.flatMap { SocialNetworkUrlProvider.resolveUrl(for: $0, urlProvider: .discord) }
}
var telegramUrl: URL? {
tokenHolderHelper.telegramUrlViewModel?.value
.flatMap { SocialNetworkUrlProvider.resolveUrl(for: $0, urlProvider: .telegram) }
}
var externalUrl: URL? {
tokenHolderHelper.externalUrlViewModel?.value
.flatMap { URL(string: $0) }
}
init(server: RPCServer, token: TokenObject, assetDefinitionStore: AssetDefinitionStore, eventsDataStore: EventsDataStoreProtocol, forWallet wallet: Wallet) {
self.server = server
self.tokenObject = token
tokenHolders = TokenAdaptor(token: token, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: wallet)
configurations = generateConfigurations(token, tokenHolders: tokenHolders)
}
let tokenHolder = tokenHolders[0]
let tokenId = tokenHolder.tokenIds[0]
var createdDateViewModel: TokenInstanceAttributeViewModel {
let string: String? = tokenHolders.first?.values.collectionCreatedDateGeneralisedTimeValue?.formatAsShortDateString()
let attributedString: NSAttributedString? = string.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}
return .init(title: R.string.localizable.semifungiblesCreatedDate(), attributedValue: attributedString)
self.tokenHolderHelper = TokenInstanceViewConfigurationHelper(tokenId: tokenId, tokenHolder: tokenHolder)
}
var descriptionViewModel: TokenInstanceAttributeViewModel? {
guard let string: String = tokenHolders.first?.values.collectionDescriptionStringValue, string.nonEmpty else { return nil }
let attributedString = TokenInstanceAttributeViewModel.defaultValueAttributedString(string, alignment: .left)
return .init(title: nil, attributedValue: attributedString, isSeparatorHidden: true)
mutating func configure(overiddenOpenSeaStats: OpenSea.Stats?) {
self.tokenHolderHelper.overiddenOpenSeaStats = overiddenOpenSeaStats
}
var backgroundColor: UIColor {
@ -65,21 +96,90 @@ struct TokensCardCollectionInfoPageViewModel {
.init(server: server)
}
func generateConfigurations(_ tokenObject: TokenObject, tokenHolders: [TokenHolder]) -> [TokensCardCollectionInfoPageViewConfiguration] {
var wikiUrlViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.wikiUrlViewModel
}
var instagramUsernameViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.instagramUsernameViewModel
}
var twitterUsernameViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.twitterUsernameViewModel
}
var discordUrlViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.discordUrlViewModel
}
var telegramUrlViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.telegramUrlViewModel
}
var externalUrlViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.externalUrlViewModel
}
var configurations: [TokensCardCollectionInfoPageViewConfiguration] {
var configurations: [TokensCardCollectionInfoPageViewConfiguration] = []
configurations = [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDetails())),
.field(viewModel: createdDateViewModel)
]
let detailsSectionViewModels = [
tokenHolderHelper.createdDateViewModel,
tokenHolderHelper.ownedAssetCountViewModel,
tokenHolderHelper.itemsCount,
tokenHolderHelper.totalVolume,
tokenHolderHelper.totalSales,
tokenHolderHelper.totalSupply,
tokenHolderHelper.owners,
tokenHolderHelper.averagePrice,
tokenHolderHelper.marketCap,
tokenHolderHelper.floorPrice,
tokenHolderHelper.numReports,
].compactMap { viewModel -> TokensCardCollectionInfoPageViewConfiguration? in
return viewModel.flatMap { .field(viewModel: $0) }
}
if let viewModel = descriptionViewModel {
if detailsSectionViewModels.isEmpty {
//no-op
} else {
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDetails())),
] + detailsSectionViewModels
}
let descriptionSectionViewModels = [
tokenHolderHelper.descriptionViewModel
].compactMap { viewModel -> TokensCardCollectionInfoPageViewConfiguration? in
return viewModel.flatMap { .field(viewModel: $0) }
}
if descriptionSectionViewModels.isEmpty {
//no-op
} else {
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDescription())),
.field(viewModel: viewModel),
]
] + descriptionSectionViewModels
}
let linksSectionViewModels = [
tokenHolderHelper.wikiUrlViewModel,
tokenHolderHelper.instagramUsernameViewModel,
tokenHolderHelper.twitterUsernameViewModel,
tokenHolderHelper.discordUrlViewModel,
tokenHolderHelper.telegramUrlViewModel,
tokenHolderHelper.externalUrlViewModel
].compactMap { viewModel -> TokensCardCollectionInfoPageViewConfiguration? in
return viewModel.flatMap { TokensCardCollectionInfoPageViewConfiguration.field(viewModel: $0) }
}
if linksSectionViewModels.isEmpty {
//no-op
} else {
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesLinks())),
] + linksSectionViewModels
}
return configurations
}
}
}

@ -179,7 +179,7 @@ class AssetsPageView: UIView, PageViewType {
@objc func doneButtonTapped() {
endEditing(true)
}
}
}
extension AssetsPageView: StatefulViewController {

@ -0,0 +1,90 @@
//
// NonFungibleTraitView.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 03.02.2022.
//
import UIKit
class NonFungibleTraitView: UIView {
private let titleLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let valueLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 1
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let countLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let separatorView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let indexPath: IndexPath
init(edgeInsets: UIEdgeInsets = .init(top: 0, left: 20, bottom: 0, right: 20), indexPath: IndexPath) {
self.indexPath = indexPath
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
let subStackView = [titleLabel, valueLabel, countLabel].asStackView(spacing: 5)
let stackView = [
.spacer(height: 0, flexible: true),
subStackView,
.spacer(height: 0, flexible: true),
separatorView
].asStackView(axis: .vertical)
stackView.translatesAutoresizingMaskIntoConstraints = false
subStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
subStackView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -1),
stackView.anchorsConstraint(to: self, edgeInsets: edgeInsets),
stackView.heightAnchor.constraint(greaterThanOrEqualToConstant: 60),
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 20),
separatorView.heightAnchor.constraint(equalToConstant: 1),
valueLabel.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 100)
])
}
required init?(coder: NSCoder) {
return nil
}
func configure(viewModel: NonFungibleTraitViewModel) {
titleLabel.attributedText = viewModel.attributedTitle
valueLabel.attributedText = viewModel.attributedValue
valueLabel.isHidden = valueLabel.attributedText == nil
countLabel.attributedText = viewModel.attributedCountValue
countLabel.isHidden = countLabel.attributedText == nil
separatorView.backgroundColor = viewModel.separatorColor
separatorView.isHidden = viewModel.isSeparatorHidden
}
}

@ -24,8 +24,11 @@ class TokenInstanceAttributeView: UIView {
private let valueLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.numberOfLines = 1
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentHuggingPriority(.required, for: .vertical)
label.setContentCompressionResistancePriority(.required, for: .vertical)
return label
}()
@ -78,7 +81,7 @@ class TokenInstanceAttributeView: UIView {
valueLabel.attributedText = viewModel.attributedValue
valueLabel.isHidden = valueLabel.attributedText == nil
valueLabel.numberOfLines = viewModel.valueLabelNumberOfLines
separatorView.backgroundColor = viewModel.separatorColor
separatorView.isHidden = viewModel.isSeparatorHidden
}

@ -8,6 +8,7 @@
import UIKit
protocol TokensCardCollectionInfoPageViewDelegate: class {
func didPressOpenWebPage(_ url: URL, in view: TokensCardCollectionInfoPageView)
func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, in view: TokensCardCollectionInfoPageView)
}
@ -36,9 +37,11 @@ class TokensCardCollectionInfoPageView: UIView, PageViewType {
private (set) var viewModel: TokensCardCollectionInfoPageViewModel
weak var delegate: TokensCardCollectionInfoPageViewDelegate?
var rightBarButtonItem: UIBarButtonItem?
init(viewModel: TokensCardCollectionInfoPageViewModel) {
private let session: WalletSession
init(viewModel: TokensCardCollectionInfoPageViewModel, session: WalletSession) {
self.viewModel = viewModel
self.session = session
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
@ -62,7 +65,7 @@ class TokensCardCollectionInfoPageView: UIView, PageViewType {
stackView.addArrangedSubview(UIView.spacer(height: 10))
stackView.addArrangedSubview(tokenIconImageView)
stackView.addArrangedSubview(UIView.spacer(height: 20))
for (index, each) in viewModel.configurations.enumerated() {
switch each {
case .header(let viewModel):
@ -79,6 +82,18 @@ class TokensCardCollectionInfoPageView: UIView, PageViewType {
}
}
func viewDidLoad() {
let values = viewModel.tokenHolders[0].values
if let openSeaSlug = values.slug, openSeaSlug.trimmed.nonEmpty {
var viewModel = viewModel
OpenSea.collectionStats(slug: openSeaSlug).done { stats in
viewModel.configure(overiddenOpenSeaStats: stats)
self.configure(viewModel: viewModel)
}.cauterize()
}
}
func configure(viewModel: TokensCardCollectionInfoPageViewModel) {
self.viewModel = viewModel
@ -97,6 +112,26 @@ class TokensCardCollectionInfoPageView: UIView, PageViewType {
extension TokensCardCollectionInfoPageView: TokenInstanceAttributeViewDelegate {
func didSelect(in view: TokenInstanceAttributeView) {
//no-op
let url: URL? = {
switch viewModel.configurations[view.indexPath.row] {
case .field(let vm) where viewModel.wikiUrlViewModel == vm:
return viewModel.wikiUrl
case .field(let vm) where viewModel.instagramUsernameViewModel == vm:
return viewModel.instagramUrl
case .field(let vm) where viewModel.twitterUsernameViewModel == vm:
return viewModel.twitterUrl
case .field(let vm) where viewModel.discordUrlViewModel == vm:
return viewModel.discordUrl
case .field(let vm) where viewModel.telegramUrlViewModel == vm:
return viewModel.telegramUrl
case .field(let vm) where viewModel.externalUrlViewModel == vm:
return viewModel.externalUrl
case .header, .field:
return .none
}
}()
guard let url = url else { return }
delegate?.didPressOpenWebPage(url, in: self)
}
}

@ -226,6 +226,9 @@ class TokenAdaptor {
values.setIssuer(string: nonFungible.issuer)
values.setCreated(string: nonFungible.created)
values.setTransferFee(string: nonFungible.transferFee)
values.setCollection(collection: nonFungible.collection)
values.setSlug(string: nonFungible.slug)
values.setCreator(creator: nonFungible.creator)
let status: Token.Status
let cryptoKittyGenerationWhenDataNotAvailable = "-1"

@ -11,7 +11,16 @@ final class TokenInstanceViewConfigurationHelper {
private let tokenId: TokenId
private let tokenHolder: TokenHolder
private let displayHelper: OpenSeaNonFungibleTokenDisplayHelper
private var openSeaCollection: OpenSea.Collection? {
values?.collectionValue
}
private var openSeaStats: OpenSea.Stats? {
overiddenOpenSeaStats ?? openSeaCollection?.stats
}
var overiddenOpenSeaStats: OpenSea.Stats?
var overridenFloorPrice: Double?
init(tokenId: TokenId, tokenHolder: TokenHolder) {
self.tokenId = tokenId
self.tokenHolder = tokenHolder
@ -23,138 +32,273 @@ final class TokenInstanceViewConfigurationHelper {
return values
}
var createdDateViewModel: TokenInstanceAttributeViewModel? {
return values?.collectionCreatedDateGeneralisedTimeValue.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0.formatAsShortDateString())
var descriptionViewModel: TokenInstanceAttributeViewModel? {
values?.collectionDescriptionStringValue.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.defaultValueAttributedString($0, alignment: .left)
}.flatMap {
.init(title: R.string.localizable.semifungiblesCreatedDate(), attributedValue: $0)
.init(title: nil, attributedValue: $0, value: $0.string, isSeparatorHidden: true)
}
}
var createdDateViewModel: TokenInstanceAttributeViewModel? {
values?.collectionCreatedDateGeneralisedTimeValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0.formatAsShortDateString()) }
.flatMap { .init(title: R.string.localizable.semifungiblesCreatedDate(), attributedValue: $0) }
}
var tokenIdViewModel: TokenInstanceAttributeViewModel? {
return values?.tokenIdStringValue.flatMap { tokenId in
.init(title: R.string.localizable.semifungiblesTokenId(), attributedValue: TokenInstanceAttributeViewModel.defaultValueAttributedString(tokenId))
}
values?.tokenIdStringValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap {
var viewModel: TokenInstanceAttributeViewModel
viewModel = .init(title: R.string.localizable.semifungiblesTokenId(), attributedValue: $0, value: values?.tokenIdStringValue)
viewModel.valueLabelNumberOfLines = 1
return viewModel
}
}
var supplyModelViewModel: TokenInstanceAttributeViewModel? {
return values?.supplyModel.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeSupplyType(), attributedValue: $0)
}
values?.supplyModel
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeSupplyType(), attributedValue: $0) }
}
var valueModelViewModel: TokenInstanceAttributeViewModel? {
values?.valueIntValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesValue(), attributedValue: $0) }
}
var transferableViewModel: TokenInstanceAttributeViewModel? {
return values?.transferable.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeTransferable(), attributedValue: $0)
}
return values?.transferable
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeTransferable(), attributedValue: $0) }
}
var meltValueViewModel: TokenInstanceAttributeViewModel? {
return values?.meltStringValue.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeMelt(), attributedValue: $0)
}
return values?.meltStringValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeMelt(), attributedValue: $0) }
}
var meltFeeRatioViewModel: TokenInstanceAttributeViewModel? {
return values?.meltFeeRatio.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeMeltFeeRatio(), attributedValue: $0)
}
return values?.meltFeeRatio
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeMeltFeeRatio(), attributedValue: $0) }
}
var meltFeeMaxRatioViewModel: TokenInstanceAttributeViewModel? {
return values?.meltFeeMaxRatio.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeMeltFeeMaxRatio(), attributedValue: $0)
}
return values?.meltFeeMaxRatio
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeMeltFeeMaxRatio(), attributedValue: $0) }
}
var totalSupplyViewModel: TokenInstanceAttributeViewModel? {
return values?.totalSupplyStringValue.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeTotalSupply(), attributedValue: $0)
}
return values?.totalSupplyStringValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeTotalSupply(), attributedValue: $0) }
}
var circulatingSupplyViewModel: TokenInstanceAttributeViewModel? {
return values?.circulatingSupplyStringValue.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeCirculatingSupply(), attributedValue: $0)
}
return values?.circulatingSupplyStringValue
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeCirculatingSupply(), attributedValue: $0) }
}
var reserveViewModel: TokenInstanceAttributeViewModel? {
return values?.reserve.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeReserve(), attributedValue: $0)
}
return values?.reserve
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeReserve(), attributedValue: $0) }
}
var nonFungibleViewModel: TokenInstanceAttributeViewModel? {
return values?.nonFungible.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeNonFungible(), attributedValue: $0)
}
return values?.nonFungible
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeNonFungible(), attributedValue: $0) }
}
var availableToMintViewModel: TokenInstanceAttributeViewModel? {
return values?.mintableSupply.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeAvailableToMint(), attributedValue: $0)
}
return values?.mintableSupply
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeAvailableToMint(), attributedValue: $0) }
}
var issuerViewModel: TokenInstanceAttributeViewModel? {
return values?.issuer.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeIssuer(), attributedValue: $0)
}
return values?.enjinIssuer
.flatMap { TokenInstanceAttributeViewModel.urlValueAttributedString($0) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeIssuer(), attributedValue: $0) }
}
var transferFeeViewModel: TokenInstanceAttributeViewModel? {
return values?.transferFee.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0))
}.flatMap {
.init(title: R.string.localizable.semifungiblesAttributeTransferFee(), attributedValue: $0)
}
return values?.transferFee
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0)) }
.flatMap { .init(title: R.string.localizable.semifungiblesAttributeTransferFee(), attributedValue: $0) }
}
var attributes: [OpenSeaNonFungibleTokenAttributeCellViewModel] {
var attributes: [NonFungibleTraitViewModel] {
let traits = tokenHolder.openSeaNonFungibleTraits ?? []
let traitsToDisplay = traits.filter { displayHelper.shouldDisplayAttribute(name: $0.type) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value, count: $0.count) }
}
var rankings: [OpenSeaNonFungibleTokenAttributeCellViewModel] {
var rankings: [NonFungibleTraitViewModel] {
let traits = tokenHolder.openSeaNonFungibleTraits ?? []
let traitsToDisplay = traits.filter { displayHelper.shouldDisplayRanking(name: $0.type) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value, count: $0.count) }
}
var stats: [OpenSeaNonFungibleTokenAttributeCellViewModel] {
var stats: [NonFungibleTraitViewModel] {
let traits = tokenHolder.openSeaNonFungibleTraits ?? []
let traitsToDisplay = traits.filter { displayHelper.shouldDisplayStat(name: $0.type) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value) }
return traitsToDisplay.map { mapTraitsToProperName(name: $0.type, value: $0.value, count: $0.count) }
}
private func mapTraitsToProperName(name: String, value: String) -> OpenSeaNonFungibleTokenAttributeCellViewModel {
private func mapTraitsToProperName(name: String, value: String, count: Int) -> NonFungibleTraitViewModel {
let displayName = displayHelper.mapTraitsToDisplayName(name: name)
let displayValue = displayHelper.mapTraitsToDisplayValue(name: name, value: value)
return OpenSeaNonFungibleTokenAttributeCellViewModel(name: displayName, value: displayValue)
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString(displayValue, alignment: .left)
let count = TokenInstanceAttributeViewModel.defaultValueAttributedString(String(count))
return .init(title: displayName, attributedValue: attributedValue, attributedCountValue: count)
}
var ownedAssetCountViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection.flatMap {
TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0.ownedAssetCount))
}.flatMap { .init(title: R.string.localizable.nonfungiblesValueOwnedAssetCount(), attributedValue: $0, value: $0.string) }
}
var wikiUrlViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.wikiUrl.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.visitWiki())
}.flatMap { .init(title: R.string.localizable.wiki(), attributedValue: $0, value: openSeaCollection?.wikiUrl) }
}
var instagramUsernameViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.instagramUsername.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.openOnInstagram())
}.flatMap { .init(title: R.string.localizable.instagram(), attributedValue: $0, value: openSeaCollection?.instagramUsername) }
}
var twitterUsernameViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.twitterUsername.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.openOnTwitter())
}.flatMap { .init(title: R.string.localizable.twitter(), attributedValue: $0, value: openSeaCollection?.twitterUsername) }
}
var discordUrlViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.discordUrl.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.openOnDiscord())
}.flatMap { .init(title: R.string.localizable.discord(), attributedValue: $0, value: openSeaCollection?.discordUrl) }
}
var telegramUrlViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.telegramUrl.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.openOnTelegram())
}.flatMap { .init(title: R.string.localizable.telegram(), attributedValue: $0, value: openSeaCollection?.telegramUrl) }
}
var externalUrlViewModel: TokenInstanceAttributeViewModel? {
openSeaCollection?.externalUrl.flatMap {
guard $0.nonEmpty else { return nil }
return TokenInstanceAttributeViewModel.urlValueAttributedString(R.string.localizable.visitWebsite())
}.flatMap { .init(title: R.string.localizable.website(), attributedValue: $0, value: openSeaCollection?.externalUrl, isSeparatorHidden: true) }
}
var itemsCount: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { StringFormatter().largeNumberFormatter(for: $0.itemsCount, currency: "", decimals: 0) }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueItemsCount(), attributedValue: attributedValue)
}
}
var totalVolume: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { Formatter.shortCrypto(symbol: RPCServer.main.symbol).string(from: $0.totalVolume) }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueTotalVolume(), attributedValue: attributedValue)
}
}
var totalSales: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { StringFormatter().largeNumberFormatter(for: $0.totalSales, currency: "") }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueTotalSales(), attributedValue: attributedValue)
}
}
var totalSupply: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { StringFormatter().largeNumberFormatter(for: $0.totalSupply, currency: "") }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueTotalSupply(), attributedValue: attributedValue)
}
}
var owners: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0.owners)) }
.flatMap {
.init(title: R.string.localizable.nonfungiblesValueOwners(), attributedValue: $0)
}
}
var averagePrice: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { Formatter.shortCrypto.string(from: $0.averagePrice) }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueAveragePrice(), attributedValue: attributedValue)
}
}
var marketCap: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { StringFormatter().largeNumberFormatter(for: $0.marketCap, currency: "") }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueMarketCap(), attributedValue: attributedValue)
}
}
var floorPrice: TokenInstanceAttributeViewModel? {
return (overridenFloorPrice ?? openSeaStats?.floorPrice)
.flatMap { Formatter.shortCrypto(symbol: RPCServer.main.symbol).string(from: $0) }
.flatMap {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString($0)
return .init(title: R.string.localizable.nonfungiblesValueFloorPrice(), attributedValue: attributedValue)
}
}
var numReports: TokenInstanceAttributeViewModel? {
return openSeaStats
.flatMap { TokenInstanceAttributeViewModel.defaultValueAttributedString(String($0.numReports)) }
.flatMap { .init(title: R.string.localizable.nonfungiblesValueNumReports(), attributedValue: $0) }
}
var creator: TokenInstanceAttributeViewModel? {
let value = values?.creatorValue?.contractAddress.eip55String
return values?.creatorValue.flatMap { creator -> String in
if let user = creator.user.flatMap({ $0.trimmed }), user.nonEmpty {
return user
} else {
return creator.contractAddress.truncateMiddle
}
}.flatMap { .init(title: "Created By", attributedValue: TokenInstanceAttributeViewModel.urlValueAttributedString($0), value: value) }
}
}

@ -36,6 +36,9 @@ protocol NonFungibleFromJson: Codable {
var issuer: String? { get }
var created: String? { get }
var transferFee: String? { get }
var slug: String { get }
var creator: OpenSea.AssetCreator? { get }
var collection: OpenSea.Collection? { get }
}
extension NonFungibleFromJson {

@ -5,6 +5,18 @@ import BigInt
//To store the output from ERC721's `tokenURI()`. The output has to be massaged to fit here as the properties was designed for OpenSea
struct NonFungibleFromTokenUri: Codable, NonFungibleFromJson {
var slug: String {
""
}
var creator: OpenSea.AssetCreator? {
return nil
}
var collection: OpenSea.Collection? {
return nil
}
let tokenId: String
let tokenType: NonFungibleFromJsonTokenType
var value: BigInt

@ -81,6 +81,19 @@ class TokenInstanceViewController: UIViewController, TokenVerifiableStatusViewCo
showNavigationBarTopSeparatorLine()
}
override func viewDidLoad() {
super.viewDidLoad()
let values = viewModel.tokenHolder.values
if let openSeaSlug = values.slug, openSeaSlug.trimmed.nonEmpty {
var viewModel = viewModel
OpenSea.collectionStats(slug: openSeaSlug).done { stats in
viewModel.configure(overiddenOpenSeaStats: stats)
self.configure(viewModel: viewModel)
}.cauterize()
}
}
private func generateSubviews(viewModel: TokenInstanceViewModel) {
let stackView = containerView.stackView
stackView.removeAllArrangedSubviews()
@ -100,11 +113,13 @@ class TokenInstanceViewController: UIViewController, TokenVerifiableStatusViewCo
view.delegate = self
subviews.append(view)
case .attributeCollection(let viewModel):
let view = OpenSeaAttributeCollectionView(viewModel: viewModel)
view.configure(viewModel: viewModel)
case .attributeCollection(let attributes):
for (row, attribute) in attributes.enumerated() {
let view = NonFungibleTraitView(indexPath: IndexPath(row: row, section: index))
view.configure(viewModel: attribute)
subviews.append(view)
subviews.append(view)
}
}
}
@ -167,13 +182,6 @@ class TokenInstanceViewController: UIViewController, TokenVerifiableStatusViewCo
return tokenHolders.first(where: { $0.tokens.contains(where: { $0.id == viewModel.tokenId }) }).flatMap { ($0, viewModel.tokenId) }
}
private func transfer() {
let transactionType = TransactionType(nonFungibleToken: tokenObject, tokenHolders: [tokenHolder])
tokenHolder.select(with: .allFor(tokenId: tokenHolder.tokenId))
delegate?.didPressTransfer(token: tokenObject, tokenHolder: tokenHolder, forPaymentFlow: .send(type: .transaction(transactionType)), in: self)
}
@objc private func actionButtonTapped(sender: UIButton) {
let actions = viewModel.actions
for (action, button) in zip(actions, buttonsBar.buttons) where button == sender {
@ -209,16 +217,23 @@ class TokenInstanceViewController: UIViewController, TokenVerifiableStatusViewCo
}
}
func redeem() {
private func transfer() {
tokenHolder.select(with: .allFor(tokenId: tokenHolder.tokenId))
let transactionType = TransactionType(nonFungibleToken: tokenObject, tokenHolders: [tokenHolder])
delegate?.didPressTransfer(token: tokenObject, tokenHolder: tokenHolder, forPaymentFlow: .send(type: .transaction(transactionType)), in: self)
}
private func redeem() {
delegate?.didPressRedeem(token: viewModel.token, tokenHolder: viewModel.tokenHolder, in: self)
}
func sell() {
let tokenHolder = viewModel.tokenHolder
private func sell() {
tokenHolder.select(with: .allFor(tokenId: tokenHolder.tokenId))
let transactionType = TransactionType.erc875Token(viewModel.token, tokenHolders: [tokenHolder])
delegate?.didPressSell(tokenHolder: tokenHolder, for: .send(type: .transaction(transactionType)), in: self)
}
}
extension TokenInstanceViewController: VerifiableStatusViewController {
@ -238,10 +253,18 @@ extension TokenInstanceViewController: VerifiableStatusViewController {
extension TokenInstanceViewController: TokenInstanceAttributeViewDelegate {
func didSelect(in view: TokenInstanceAttributeView) {
switch viewModel.configurations[view.indexPath.row] {
case .field(let viewModel) where self.viewModel.tokenIdViewModel == viewModel:
UIPasteboard.general.string = viewModel.value
case .field(let vm) where viewModel.tokenIdViewModel == vm:
UIPasteboard.general.string = vm.value
self.view.showCopiedToClipboard(title: R.string.localizable.copiedToClipboard())
case .field(let vm) where viewModel.creatorViewModel == vm:
guard let url = viewModel.creatorOnOpenSeaUrl else { return }
delegate?.didPressViewContractWebPage(url, in: self)
case .field(let vm) where viewModel.contractViewModel == vm:
guard let url = viewModel.contractOnExplorerUrl else { return }
delegate?.didPressViewContractWebPage(url, in: self)
case .header, .field, .attributeCollection:
break
}

@ -8,20 +8,13 @@
import Foundation
import UIKit
import Result
protocol TokensCardViewControllerDelegate: class, CanOpenURL {
func didSelectAssetSelection(in viewController: TokensCardViewController)
func didTap(transaction: TransactionInstance, in viewController: TokensCardViewController)
func didTap(activity: Activity, in viewController: TokensCardViewController)
func didSelectTokenHolder(in viewController: TokensCardViewController, didSelectTokenHolder tokenHolder: TokenHolder)
func didPressRedeem(token: TokenObject, tokenHolder: TokenHolder, in viewController: TokensCardViewController)
func didPressSell(tokenHolder: TokenHolder, for paymentFlow: PaymentFlow, in viewController: TokensCardViewController)
func didPressTransfer(token: TokenObject, tokenHolder: TokenHolder, for type: PaymentFlow, in viewController: TokensCardViewController)
func didCancel(in viewController: TokensCardViewController)
func didPressViewRedemptionInfo(in viewController: TokensCardViewController)
func didTapURL(url: URL, in viewController: TokensCardViewController)
func didTapTokenInstanceIconified(tokenHolder: TokenHolder, in viewController: TokensCardViewController)
func didTap(action: TokenInstanceAction, tokenHolder: TokenHolder, viewController: TokensCardViewController)
}
class TokensCardViewController: UIViewController {
@ -33,26 +26,51 @@ class TokensCardViewController: UIViewController {
private let assetDefinitionStore: AssetDefinitionStore
private let eventsDataStore: EventsDataStoreProtocol
private let analyticsCoordinator: AnalyticsCoordinator
private let buttonsBar = ButtonsBar(configuration: .combined(buttons: 2))
private lazy var buttonsBar: ButtonsBar = {
let buttonsBar = ButtonsBar(configuration: .empty)
buttonsBar.viewController = self
return buttonsBar
}()
private let tokenScriptFileStatusHandler: XMLHandler
weak var delegate: TokensCardViewControllerDelegate?
private let tokensCardCollectionInfoPageView: TokensCardCollectionInfoPageView
private var activitiesPageView: ActivitiesPageView
private var assetsPageView: AssetsPageView
private let containerView: PagesContainerView
private lazy var collectionInfoPageView: TokensCardCollectionInfoPageView = {
let viewModel: TokensCardCollectionInfoPageViewModel = .init(server: session.server, token: tokenObject, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore, forWallet: session.account)
let view = TokensCardCollectionInfoPageView(viewModel: viewModel, session: session)
view.delegate = self
private var selectedTokenHolder: TokenHolder? {
let selectedTokenHolders = viewModel.tokenHolders.filter { $0.isSelected }
return selectedTokenHolders.first
}
return view
}()
private lazy var activitiesPageView: ActivitiesPageView = {
let viewModel: ActivityPageViewModel = .init(activitiesViewModel: .init())
let view = ActivitiesPageView(viewModel: viewModel, sessions: activitiesService.sessions)
view.delegate = self
return view
}()
private lazy var assetsPageView: AssetsPageView = {
let viewModel: AssetsPageViewModel = .init(tokenHolders: viewModel.tokenHolders, selection: .list)
let view = AssetsPageView(assetDefinitionStore: assetDefinitionStore, viewModel: viewModel)
view.delegate = self
view.rightBarButtonItem = UIBarButtonItem.switchGridToListViewBarButton(
selection: viewModel.selection.inverted,
self,
selector: #selector(assetSelectionSelected)
)
view.searchBar.delegate = self
view.collectionView.refreshControl = refreshControl
return view
}()
private let account: Wallet
private let refreshControl = UIRefreshControl()
private lazy var keyboardChecker: KeyboardChecker = {
let buttonsBarHeight: CGFloat = UIApplication.shared.bottomSafeAreaHeight > 0 ? -UIApplication.shared.bottomSafeAreaHeight : 0
return KeyboardChecker(self, resetHeightDefaultValue: 0, ignoreBottomSafeArea: true, buttonsBarHeight: buttonsBarHeight)
return KeyboardChecker(self, resetHeightDefaultValue: 0, ignoreBottomSafeArea: true)
}()
private let activitiesService: ActivitiesServiceType
init(session: WalletSession, assetDefinition: AssetDefinitionStore, analyticsCoordinator: AnalyticsCoordinator, token: TokenObject, viewModel: TokensCardViewModel, activitiesService: ActivitiesServiceType, eventsDataStore: EventsDataStoreProtocol) {
self.tokenObject = token
@ -63,24 +81,19 @@ class TokensCardViewController: UIViewController {
self.assetDefinitionStore = assetDefinition
self.eventsDataStore = eventsDataStore
self.analyticsCoordinator = analyticsCoordinator
self.activitiesPageView = ActivitiesPageView(viewModel: .init(activitiesViewModel: .init()), sessions: activitiesService.sessions)
self.assetsPageView = AssetsPageView(assetDefinitionStore: assetDefinitionStore, viewModel: .init(tokenHolders: viewModel.tokenHolders, selection: .list))
let footerBar = ButtonsBarBackgroundView(buttonsBar: buttonsBar)
tokensCardCollectionInfoPageView = TokensCardCollectionInfoPageView(viewModel: .init(server: session.server, token: tokenObject, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore, forWallet: session.account))
let pageWithFooter = PageViewWithFooter(pageView: tokensCardCollectionInfoPageView, footerBar: footerBar)
containerView = PagesContainerView(pages: [pageWithFooter, assetsPageView, activitiesPageView], selectedIndex: viewModel.initiallySelectedTabIndex)
self.activitiesService = activitiesService
super.init(nibName: nil, bundle: nil)
hidesBottomBarWhenPushed = true
activitiesPageView.delegate = self
assetsPageView.delegate = self
containerView.delegate = self
let footerBar = ButtonsBarBackgroundView(buttonsBar: buttonsBar)
let pageWithFooter = PageViewWithFooter(pageView: collectionInfoPageView, footerBar: footerBar)
let pages: [PageViewType] = [pageWithFooter, assetsPageView, activitiesPageView]
let containerView = PagesContainerView(pages: pages, selectedIndex: viewModel.initiallySelectedTabIndex)
containerView.delegate = self
view.addSubview(containerView)
NSLayoutConstraint.activate([containerView.anchorsConstraint(to: view)])
navigationItem.largeTitleDisplayMode = .never
@ -90,14 +103,31 @@ class TokensCardViewController: UIViewController {
view.configure(viewModel: .init(activitiesViewModel: viewModel))
}
assetsPageView.rightBarButtonItem = UIBarButtonItem.switchGridToListViewBarButton(
selection: assetsPageView.viewModel.selection.inverted,
self,
selector: #selector(assetSelectionSelected)
)
assetsPageView.searchBar.delegate = self
assetsPageView.collectionView.refreshControl = refreshControl
keyboardChecker.constraints = containerView.bottomAnchorConstraints
configure(assetsPageView: assetsPageView, viewModel: viewModel)
}
private func configure(assetsPageView: AssetsPageView, viewModel: TokensCardViewModel) {
switch viewModel.token.type {
case .erc1155:
//TODO disabled until we support batch transfers. Selection doesn't work correctly too
assetsPageView.rightBarButtonItem = UIBarButtonItem.selectBarButton(self, selector: #selector(assetSelectionSelected))
switch session.account.type {
case .real:
assetsPageView.rightBarButtonItem?.isEnabled = true
case .watch:
assetsPageView.rightBarButtonItem?.isEnabled = false
}
case .erc721, .erc721ForTickets:
let selection = assetsPageView.viewModel.selection.inverted
let buttonItem = UIBarButtonItem.switchGridToListViewBarButton(selection: selection, self, selector: #selector(assetsDisplayTypeSelected))
assetsPageView.rightBarButtonItem = buttonItem
case .erc20, .nativeCryptocurrency, .erc875:
break
}
}
required init?(coder aDecoder: NSCoder) {
@ -106,6 +136,7 @@ class TokensCardViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
hideNavigationBarTopSeparatorLine()
}
@ -117,6 +148,7 @@ class TokensCardViewController: UIViewController {
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
keyboardChecker.viewWillDisappear()
showNavigationBarTopSeparatorLine()
}
@ -126,6 +158,8 @@ class TokensCardViewController: UIViewController {
configure(viewModel: viewModel)
refreshControl.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
collectionInfoPageView.viewDidLoad()
}
@objc private func didPullToRefresh(_ sender: UIRefreshControl) {
@ -143,35 +177,16 @@ class TokensCardViewController: UIViewController {
title = viewModel.navigationTitle
updateNavigationRightBarButtons(tokenScriptFileStatusHandler: tokenScriptFileStatusHandler)
tokensCardCollectionInfoPageView.configure(viewModel: .init(server: session.server, token: tokenObject, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore, forWallet: session.account))
collectionInfoPageView.configure(viewModel: .init(server: session.server, token: tokenObject, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore, forWallet: session.account))
assetsPageView.configure(viewModel: .init(tokenHolders: viewModel.tokenHolders, selection: assetsPageView.viewModel.selection))
let actions = viewModel.actions
buttonsBar.configure(.combined(buttons: viewModel.actions.count))
buttonsBar.viewController = self
func _configButton(action: TokenInstanceAction, button: BarButton) {
if let selection = action.activeExcludingSelection(selectedTokenHolder: viewModel.tokenHolders[0], tokenId: viewModel.tokenHolders[0].tokenId, forWalletAddress: session.account.address, fungibleBalance: viewModel.fungibleBalance) {
if selection.denial == nil {
button.displayButton = false
}
}
}
for (action, button) in zip(actions, buttonsBar.buttons) {
button.setTitle(action.name, for: .normal)
if collectionInfoPageView.viewModel.openInUrl != nil {
buttonsBar.configure(.secondary(buttons: 1))
let button = buttonsBar.buttons[0]
button.setTitle(R.string.localizable.openOnOpenSea(), for: .normal)
button.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside)
switch session.account.type {
case .real:
_configButton(action: action, button: button)
case .watch:
//TODO pass in a Config instance instead
if Config().development.shouldPretendIsRealWallet {
_configButton(action: action, button: button)
} else {
button.isEnabled = false
}
}
} else {
buttonsBar.configure(.empty)
}
}
@ -179,7 +194,7 @@ class TokensCardViewController: UIViewController {
let tokenScriptStatusPromise = xmlHandler.tokenScriptStatus
if tokenScriptStatusPromise.isPending {
let label: UIBarButtonItem = .init(title: R.string.localizable.tokenScriptVerifying(), style: .plain, target: nil, action: nil)
tokensCardCollectionInfoPageView.rightBarButtonItem = label
collectionInfoPageView.rightBarButtonItem = label
tokenScriptStatusPromise.done { [weak self] _ in
self?.updateNavigationRightBarButtons(tokenScriptFileStatusHandler: xmlHandler)
@ -189,75 +204,20 @@ class TokensCardViewController: UIViewController {
if let server = xmlHandler.server, let status = tokenScriptStatusPromise.value, server.matches(server: session.server) {
switch status {
case .type0NoTokenScript:
tokensCardCollectionInfoPageView.rightBarButtonItem = nil
collectionInfoPageView.rightBarButtonItem = nil
case .type1GoodTokenScriptSignatureGoodOrOptional, .type2BadTokenScript:
let button = createTokenScriptFileStatusButton(withStatus: status, urlOpener: self)
tokensCardCollectionInfoPageView.rightBarButtonItem = UIBarButtonItem(customView: button)
collectionInfoPageView.rightBarButtonItem = UIBarButtonItem(customView: button)
}
} else {
tokensCardCollectionInfoPageView.rightBarButtonItem = nil
collectionInfoPageView.rightBarButtonItem = nil
}
}
@objc private func actionButtonTapped(sender: UIButton) {
let actions = viewModel.actions
for (action, button) in zip(actions, buttonsBar.buttons) where button == sender {
handle(action: action)
break
}
}
private func handle(action: TokenInstanceAction) {
viewModel.markHolderSelected()
guard let tokenHolder = selectedTokenHolder else { return }
switch action.type {
case .erc20Send, .erc20Receive, .swap, .buy, .bridge:
break
case .nftRedeem:
redeem()
case .nftSell:
sell()
case .nonFungibleTransfer:
transfer()
case .tokenScript:
if let selection = action.activeExcludingSelection(selectedTokenHolders: [tokenHolder], forWalletAddress: account.address) {
if let denialMessage = selection.denial {
UIAlertController.alert(
title: nil,
message: denialMessage,
alertButtonTitles: [R.string.localizable.oK()],
alertButtonStyles: [.default],
viewController: self,
completion: nil
)
} else {
//no-op shouldn't have reached here since the button should be disabled. So just do nothing to be safe
}
} else {
delegate?.didTap(action: action, tokenHolder: tokenHolder, viewController: self)
}
}
}
func redeem() {
guard let selectedTokenHolder = selectedTokenHolder else { return }
delegate?.didPressRedeem(token: viewModel.token, tokenHolder: selectedTokenHolder, in: self)
}
func sell() {
guard let selectedTokenHolder = selectedTokenHolder else { return }
let transactionType = TransactionType.erc875Token(viewModel.token, tokenHolders: [selectedTokenHolder])
delegate?.didPressSell(tokenHolder: selectedTokenHolder, for: .send(type: .transaction(transactionType)), in: self)
}
func transfer() {
guard let selectedTokenHolder = selectedTokenHolder else { return }
let transactionType = TransactionType(nonFungibleToken: viewModel.token, tokenHolders: [selectedTokenHolder])
delegate?.didPressTransfer(token: viewModel.token, tokenHolder: selectedTokenHolder, for: .send(type: .transaction(transactionType)), in: self)
guard let url = collectionInfoPageView.viewModel.openInUrl else { return }
delegate?.didPressOpenWebPage(url, in: self)
}
}
extension TokensCardViewController: UISearchBarDelegate {
@ -274,6 +234,10 @@ extension TokensCardViewController: PagesContainerViewDelegate {
}
@objc private func assetSelectionSelected(_ sender: UIBarButtonItem) {
delegate?.didSelectAssetSelection(in: self)
}
@objc private func assetsDisplayTypeSelected(_ sender: UIBarButtonItem) {
let selection = assetsPageView.viewModel.selection
assetsPageView.configure(viewModel: .init(tokenHolders: viewModel.tokenHolders, selection: sender.selection ?? selection))
sender.toggleSelection()
@ -287,6 +251,11 @@ extension TokensCardViewController: CanOpenURL2 {
}
extension TokensCardViewController: TokensCardCollectionInfoPageViewDelegate {
func didPressOpenWebPage(_ url: URL, in view: TokensCardCollectionInfoPageView) {
delegate?.didPressOpenWebPage(url, in: self)
}
func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, in view: TokensCardCollectionInfoPageView) {
delegate?.didPressViewContractWebPage(forContract: contract, server: session.server, in: self)
}

@ -82,21 +82,21 @@ struct TokenInstanceInfoPageViewModel {
if !tokenHolderHelper.attributes.isEmpty {
previewViewModels += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesAttributes())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.attributes))
.attributeCollection(viewModel: tokenHolderHelper.attributes)
]
}
if !tokenHolderHelper.stats.isEmpty {
previewViewModels += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesStats())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.stats))
.attributeCollection(viewModel: tokenHolderHelper.stats)
]
}
if !tokenHolderHelper.rankings.isEmpty {
previewViewModels += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesRankings())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.rankings))
.attributeCollection(viewModel: tokenHolderHelper.rankings)
]
}

@ -11,7 +11,7 @@ import BigInt
enum TokenInstanceViewConfiguration {
case header(viewModel: TokenInfoHeaderViewModel)
case field(viewModel: TokenInstanceAttributeViewModel)
case attributeCollection(viewModel: OpenSeaOpenSeaAttributeCollectionViewModel)
case attributeCollection(viewModel: [NonFungibleTraitViewModel])
}
enum TokenInstanceViewMode {
@ -35,6 +35,11 @@ struct TokenInstanceViewModel {
self.assetDefinitionStore = assetDefinitionStore
self.displayHelper = OpenSeaNonFungibleTokenDisplayHelper(contract: tokenHolder.contractAddress)
self.tokenHolderHelper = TokenInstanceViewConfigurationHelper(tokenId: tokenId, tokenHolder: tokenHolder)
self.contractViewModel = TokenInstanceAttributeViewModel(title: R.string.localizable.nonfungiblesValueContract(), attributedValue: TokenInstanceAttributeViewModel.urlValueAttributedString(token.contractAddress.truncateMiddle))
}
mutating func configure(overiddenOpenSeaStats: OpenSea.Stats?) {
self.tokenHolderHelper.overridenFloorPrice = overiddenOpenSeaStats?.floorPrice
}
var actions: [TokenInstanceAction] {
@ -44,7 +49,7 @@ struct TokenInstanceViewModel {
return actionsFromTokenScript
} else {
switch token.type {
case .erc1155:
case .erc1155, .erc721:
return [
.init(type: .nonFungibleTransfer)
]
@ -53,10 +58,6 @@ struct TokenInstanceViewModel {
.init(type: .nftSell),
.init(type: .nonFungibleTransfer)
]
case .erc721:
return [
.init(type: .nonFungibleTransfer)
]
case .nativeCryptocurrency, .erc20:
return []
}
@ -67,21 +68,31 @@ struct TokenInstanceViewModel {
tokenHolderHelper.tokenIdViewModel
}
var creatorOnOpenSeaUrl: URL? {
return tokenHolder.values.creatorValue
.flatMap { URL(string: "https://opensea.io/\($0.contractAddress)?tab=created") }
}
var contractOnExplorerUrl: URL? {
ConfigExplorer(server: token.server)
.contractUrl(address: token.contractAddress)?.url
}
var creatorViewModel: TokenInstanceAttributeViewModel? {
tokenHolderHelper.creator
}
var contractViewModel: TokenInstanceAttributeViewModel
var tokenImagePlaceholder: UIImage? {
return R.image.tokenPlaceholderLarge()
}
var configurations: [TokenInstanceViewConfiguration] {
guard let values = tokenHolderHelper.values else { return [] }
var configurations: [TokenInstanceViewConfiguration] = []
var previewViewModels: [TokenInstanceViewConfiguration] = []
if let viewModel = tokenIdViewModel {
previewViewModels += [
.field(viewModel: viewModel)
]
}
previewViewModels += [
configurations += [
tokenHolderHelper.valueModelViewModel,
tokenHolderHelper.issuerViewModel,
tokenHolderHelper.transferFeeViewModel,
tokenHolderHelper.createdDateViewModel,
@ -95,50 +106,65 @@ struct TokenInstanceViewModel {
tokenHolderHelper.availableToMintViewModel,
tokenHolderHelper.transferableViewModel,
].compactMap { each -> TokenInstanceViewConfiguration? in
return each.flatMap { TokenInstanceViewConfiguration.field(viewModel: $0) }
}
let value: BigInt = values.valueIntValue ?? 0
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString(String(value))
previewViewModels += [
.field(viewModel: .init(title: R.string.localizable.semifungiblesValue(), attributedValue: attributedValue))
]
if let description = values.descriptionAssetInternalValue?.resolvedValue?.stringValue.nilIfEmpty {
let attributedValue = TokenInstanceAttributeViewModel.defaultValueAttributedString(description, alignment: .left)
previewViewModels += [
return each.flatMap { .field(viewModel: $0) }
}
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDetails())),
] + [
tokenHolderHelper.creator,
tokenHolderHelper.tokenIdViewModel,
contractViewModel,
TokenInstanceAttributeViewModel(title: R.string.localizable.nonfungiblesValueBlockchain(), attributedValue: TokenInstanceAttributeViewModel.defaultValueAttributedString(token.server.blockChainName)),
TokenInstanceAttributeViewModel(title: R.string.localizable.nonfungiblesValueTokenStandard(), attributedValue: TokenInstanceAttributeViewModel.defaultValueAttributedString(token.type.rawValue))
].compactMap { each -> TokenInstanceViewConfiguration? in
return each.flatMap { .field(viewModel: $0) }
}
configurations += [
tokenHolderHelper.itemsCount,
tokenHolderHelper.totalVolume,
tokenHolderHelper.totalSales,
tokenHolderHelper.totalSupply,
tokenHolderHelper.owners,
tokenHolderHelper.averagePrice,
tokenHolderHelper.floorPrice
].compactMap { viewModel -> TokenInstanceViewConfiguration? in
return viewModel.flatMap { .field(viewModel: $0) }
}
if let viewModel = tokenHolderHelper.descriptionViewModel {
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDescription())),
.field(viewModel: .init(title: nil, attributedValue: attributedValue, isSeparatorHidden: true))
]
.field(viewModel: viewModel)
]
}
if !tokenHolderHelper.attributes.isEmpty {
previewViewModels += [
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesAttributes())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.attributes))
.attributeCollection(viewModel: tokenHolderHelper.attributes)
]
}
if !tokenHolderHelper.stats.isEmpty {
previewViewModels += [
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesStats())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.stats))
.attributeCollection(viewModel: tokenHolderHelper.stats)
]
}
if !tokenHolderHelper.rankings.isEmpty {
previewViewModels += [
configurations += [
.header(viewModel: .init(title: R.string.localizable.semifungiblesRankings())),
.attributeCollection(viewModel: .init(attributes: tokenHolderHelper.rankings))
.attributeCollection(viewModel: tokenHolderHelper.rankings)
]
}
return [
.header(viewModel: .init(title: R.string.localizable.semifungiblesDetails()))
] + previewViewModels
return configurations
}
var navigationTitle: String? {
var navigationTitle: String {
let tokenId = tokenHolder.values.tokenIdStringValue ?? ""
if let name = tokenHolder.values.nameStringValue.nilIfEmpty {
return name

@ -25,41 +25,10 @@ struct TokensCardViewModel {
let token: TokenObject
var tokenHolders: [TokenHolder]
var actions: [TokenInstanceAction] {
//NOTE: Show actions only in case when there is only one token id in list, othervise user is able to select each toke to perform an action
guard numberOfItems() == 1 else { return [] }
let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore)
let actionsFromTokenScript = xmlHandler.actions
if actionsFromTokenScript.isEmpty {
switch token.type {
case .erc875, .erc721ForTickets:
return [
.init(type: .nftSell),
.init(type: .nonFungibleTransfer)
]
case .erc721, .erc1155:
return [
.init(type: .nonFungibleTransfer)
]
case .nativeCryptocurrency, .erc20:
return []
}
} else {
return actionsFromTokenScript
}
}
func item(for indexPath: IndexPath) -> TokenHolder {
return tokenHolders[indexPath.section]
}
func markHolderSelected() {
//NOTE: Toggle only in case when there is only one tokenHolder
guard let token = tokenHolders.first, tokenHolders.count == 1 else { return }
token.isSelected = true
}
var backgroundColor: UIColor {
return Colors.appBackground
}
@ -67,6 +36,7 @@ struct TokensCardViewModel {
var navigationTitle: String {
return token.titleInPluralForm(withAssetDefinitionStore: assetDefinitionStore)
}
private let eventsDataStore: EventsDataStoreProtocol
private let account: Wallet

@ -1,74 +0,0 @@
//
// OpenSeaAttributeCollectionView.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 19.01.2022.
//
import UIKit
struct OpenSeaOpenSeaAttributeCollectionViewModel {
var backgroundColor: UIColor = Colors.appBackground
var attributes: [OpenSeaNonFungibleTokenAttributeCellViewModel]
}
class OpenSeaAttributeCollectionView: UIView {
private (set) var viewModel: OpenSeaOpenSeaAttributeCollectionViewModel
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
//3-column for iPhone 6s and above, 2-column for iPhone 5
layout.itemSize = CGSize(width: 105, height: 30)
layout.minimumLineSpacing = 10
layout.minimumInteritemSpacing = 00
let view = SelfResizableCollectionView(frame: .zero, collectionViewLayout: layout)
view.register(OpenSeaNonFungibleTokenTraitCell.self)
view.isUserInteractionEnabled = false
view.dataSource = self
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
init(viewModel: OpenSeaOpenSeaAttributeCollectionViewModel, edgeInsets: UIEdgeInsets = .init(top: 0, left: 16, bottom: 16, right: 16)) {
self.viewModel = viewModel
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
addSubview(collectionView)
NSLayoutConstraint.activate(collectionView.anchorsConstraint(to: self, edgeInsets: edgeInsets))
configure(viewModel: viewModel)
}
func configure(viewModel: OpenSeaOpenSeaAttributeCollectionViewModel) {
self.viewModel = viewModel
collectionView.backgroundColor = viewModel.backgroundColor
backgroundColor = viewModel.backgroundColor
collectionView.reloadData()
}
required init?(coder: NSCoder) {
return nil
}
}
extension OpenSeaAttributeCollectionView: UICollectionViewDataSource {
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return viewModel.attributes.count
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell: OpenSeaNonFungibleTokenTraitCell = collectionView.dequeueReusableCell(for: indexPath)
let viewModel = self.viewModel.attributes[indexPath.row]
cell.configure(viewModel: .init(name: viewModel.name, value: viewModel.value))
return cell
}
}

@ -73,11 +73,13 @@ class TokenInstanceInfoPageView: UIView, PageViewType {
view.configure(viewModel: viewModel)
view.delegate = self
stackView.addArrangedSubview(view)
case .attributeCollection(let viewModel):
let view = OpenSeaAttributeCollectionView(viewModel: viewModel)
view.configure(viewModel: viewModel)
case .attributeCollection(let attributes):
for (row, attribute) in attributes.enumerated() {
let view = NonFungibleTraitView(indexPath: IndexPath(row: row, section: index))
view.configure(viewModel: attribute)
stackView.addArrangedSubview(view)
stackView.addArrangedSubview(view)
}
}
}
}
@ -95,7 +97,7 @@ class TokenInstanceInfoPageView: UIView, PageViewType {
@objc private func showContractWebPage() {
delegate?.didPressViewContractWebPage(forContract: viewModel.contractAddress, in: self)
}
}
}
extension TokenInstanceInfoPageView: TokenInstanceAttributeViewDelegate {

@ -38,11 +38,10 @@ extension MoyaProvider {
}
}
func attempt<T>(maximumRetryCount: Int = 3, delayBeforeRetry: DispatchTimeInterval = .seconds(1), _ body: @escaping () -> Promise<T>) -> Promise<T> {
func attempt<T>(maximumRetryCount: Int = 3, delayBeforeRetry: DispatchTimeInterval = .seconds(1), delayUpperRangeValueFrom0To: Int = 5, _ body: @escaping () -> Promise<T>) -> Promise<T> {
var attempts = 0
func attempt() -> Promise<T> {
attempts += 1
return body().recover { error -> Promise<T> in
guard attempts < maximumRetryCount else {
throw error
@ -51,10 +50,29 @@ func attempt<T>(maximumRetryCount: Int = 3, delayBeforeRetry: DispatchTimeInterv
if case PMKError.cancelled = error {
throw error
}
return after(delayBeforeRetry).then(on: nil, attempt)
return after(delayBeforeRetry.nextRandomInterval(upTo: delayUpperRangeValueFrom0To)).then(on: nil, attempt)
}
}
return attempt()
}
private extension DispatchTimeInterval {
func nextRandomInterval(upTo value: Int = 5) -> DispatchTimeInterval {
let jitter = Int.random(in: 0 ..< value)
switch self {
case .microseconds(let value):
return .microseconds(value + jitter)
case .milliseconds(let value):
return .milliseconds(value + jitter)
case .nanoseconds(let value):
return .nanoseconds(value + jitter)
case .never:
return .never
case .seconds(let value):
return .seconds(value + jitter)
}
}
}

@ -7,7 +7,6 @@
import Foundation
import UIKit
import Result
import SafariServices
import MessageUI
import BigInt
@ -411,10 +410,15 @@ class TokensCardCoordinator: NSObject, Coordinator {
private func showTokenInstanceActionView(forAction action: TokenInstanceAction, tokenHolder: TokenHolder, viewController: UIViewController) {
delegate?.didPress(for: .send(type: .tokenScript(action: action, tokenObject: token, tokenHolder: tokenHolder)), inViewController: viewController, in: self)
}
}
}
extension TokensCardCoordinator: TokensCardViewControllerDelegate {
func didSelectAssetSelection(in viewController: TokensCardViewController) {
//no-op
}
func didTap(transaction: TransactionInstance, in viewController: TokensCardViewController) {
delegate?.didTap(transaction: transaction, in: self)
}

@ -60,11 +60,11 @@ struct TransactionDetailsViewModel {
}
var detailsURL: URL? {
return ConfigExplorer(server: server).transactionURL(for: transactionRow.id)?.url
return ConfigExplorer(server: server).transactionUrl(for: transactionRow.id)?.url
}
var detailsButtonText: String {
if let name = ConfigExplorer(server: server).transactionURL(for: transactionRow.id)?.name {
if let name = ConfigExplorer(server: server).transactionUrl(for: transactionRow.id)?.name {
return R.string.localizable.viewIn(name)
} else {
return R.string.localizable.moreDetails()

@ -62,7 +62,7 @@ class ActivitiesPageView: UIView, PageViewType {
activitiesView.applySearch(keyword: nil)
activitiesView.endLoading()
}
}
}
extension ActivitiesPageView: ActivitiesViewDelegate {

@ -129,7 +129,7 @@ class TokenInfoPageView: UIView, PageViewType {
@objc private func refreshHeaderView() {
viewModel.isShowingValue.toggle()
headerView.configure(viewModel: viewModel)
}
}
}
extension TokenInfoPageView: SendHeaderViewDelegate {

@ -130,7 +130,7 @@ class PagesContainerView: RoundedBackground {
}
let offset = CGPoint(x: CGFloat(index) * scrollView.bounds.width, y: 0)
scrollView.setContentOffset(offset, animated: false)
scrollView.setContentOffset(offset, animated: animated)
delegate?.containerView(self, didSelectPage: index)
}
@ -176,5 +176,5 @@ class PageViewWithFooter: UIView, PageViewType {
required init?(coder: NSCoder) {
return nil
}
}
}

Loading…
Cancel
Save