Compare commits

...
204 Commits
Author SHA1 Message Date
Chris Troutner faec078403 Merge pull request #54 from Permissionless-Software-Foundation/dh-nostr-library
feat(nostr): Created Nostr service library
2025-08-21 07:19:00 -07:00
Daniel Gonzalez 416941089b feat(nostr): Created Nostr service library 2025-08-20 20:39:05 -04:00
Chris Troutner f4c47d4ce3 Merge pull request #53 from Permissionless-Software-Foundation/dh-profile-todos
feat(nostr): Completed profile feed TODOs
2025-08-15 11:58:43 -07:00
Chris Troutner 121f1001e1 Merge branch 'master' into dh-profile-todos 2025-08-15 10:36:20 -07:00
Chris Troutner e8f077dba2 Merge pull request #52 from Permissionless-Software-Foundation/dh-feeds-todos
feat(nostr): Completed TODOs on feeds page
2025-08-15 10:35:37 -07:00
Chris Troutner 3fbbdbc6a7 minor edits 2025-08-15 10:33:47 -07:00
Daniel Gonzalez 43013bd464 feat(nostr): Completed profile feed TODOs 2025-08-15 12:55:34 -04:00
Daniel Gonzalez 391ddb62de feat(nostr): Completed TODOs on feeds page 2025-08-14 11:39:31 -04:00
Chris Troutner a7c3fc243b Merge pull request #51 from Permissionless-Software-Foundation/dh-nostr-relays
feat(nostr): Handle multiple nostr relays
2025-08-13 08:04:01 -07:00
Chris Troutner 173cda717e Adding comments 2025-08-13 08:02:50 -07:00
Chris Troutner e46068165f Removing completed todos 2025-08-13 08:00:52 -07:00
Daniel Gonzalez 086deb4904 feat(nostr): Handle multiple nostr relays 2025-08-12 18:51:33 -04:00
Chris Troutner 5386a07a62 Merge pull request #50 from Permissionless-Software-Foundation/ct-unstable
fix(bch-dex-lib): Updating to v2.2.0
2025-08-10 11:08:07 -07:00
Chris Troutner a240fab588 fix(bch-dex-lib): Updating to v2.2.0 2025-08-10 11:04:58 -07:00
Chris Troutner 0ab5702b48 Merge pull request #49 from Permissionless-Software-Foundation/ct-unstable
fix(feeds): fixing duplicate issues. Adding TODO item
2025-08-08 08:09:07 -07:00
Chris Troutner 97dd816330 fix(feeds): fixing duplicate issues. Adding TODO item 2025-08-08 08:07:53 -07:00
Chris Troutner b878939456 Merge pull request #48 from Permissionless-Software-Foundation/ct-unstable
fix(feeds): Removing duplicate posts
2025-08-06 19:49:30 -07:00
Chris Troutner 59cfecb485 fix(feeds): Removing duplicate posts 2025-08-06 19:48:09 -07:00
Chris Troutner bd3798f5aa Merge pull request #47 from Permissionless-Software-Foundation/dh-feed-pfps
feat(nostr): Added profile photos on feeds
2025-08-06 19:37:25 -07:00
Daniel Gonzalez 5f0103ba81 Merge branch 'master' into dh-feed-pfps 2025-08-06 21:54:44 -04:00
Daniel Gonzalez 55ebb7b25d feat(nostr): Added profile photos on feeds 2025-08-06 21:53:11 -04:00
Chris Troutner 44246a78d5 Merge pull request #46 from Permissionless-Software-Foundation/ct-unstable
fix(content creators): Fixing bug. Adding TODO items
2025-08-06 12:23:09 -07:00
Chris Troutner b32bbdae0a fix(content creators): Fixing bug. Adding TODO items 2025-08-06 12:21:03 -07:00
Chris Troutner f9c31b61c7 Merge pull request #45 from Permissionless-Software-Foundation/ct-unstable
fix(Nostr): Reading from pool of relays whenever we can
2025-08-04 12:55:46 -07:00
Chris Troutner e327704b57 fix(Nostr): Reading from pool of relays whenever we can 2025-08-04 12:51:16 -07:00
Chris Troutner c84b8ed9c0 Merge pull request #44 from Permissionless-Software-Foundation/ct-unstable
Minor design improvements
2025-08-04 12:38:59 -07:00
Chris Troutner 59f49f9813 fix(Token Info): Turning 'View User Data' link into a button 2025-08-04 12:37:41 -07:00
Chris Troutner 44537e9cdd fix(Nostr Profile Post): Posting to multiple relays 2025-08-04 12:22:19 -07:00
Chris Troutner e547c97a44 fix(Nostr Public Post): Posting to multiple Nostr relays 2025-08-04 12:18:49 -07:00
Chris Troutner 880a07819c fix(Nostr Post): Opening up public post on page load 2025-08-04 11:53:44 -07:00
Chris Troutner a1f2c78059 Merge pull request #43 from Permissionless-Software-Foundation/dh-userdata-loading
feat(user-data): Added loading optimization to user-data page
2025-08-03 08:32:24 -07:00
Daniel Gonzalez 1d6ebbfd92 feat(user-data): Added loading optimization to user-data page 2025-08-02 20:22:16 -04:00
Chris Troutner 7e168c6cf1 Merge pull request #42 from Permissionless-Software-Foundation/dh-tokens-sale
feat(profile): Added tokens-for-sale section to profile
2025-08-01 10:54:41 -07:00
Daniel Gonzalez b031fa98de feat(profile): Added tokens-for-sale section to profile 2025-08-01 12:43:01 -04:00
Chris Troutner d708d88c3c Merge pull request #41 from Permissionless-Software-Foundation/dh-loading-time
feat(nostr): used '#' to reduce page loading time on profile page
2025-07-30 08:03:39 -07:00
Daniel Gonzalez f80c43f35e feat(nostr): used '#' to reduce page loading time on profile page 2025-07-29 20:00:14 -04:00
Daniel Gonzalez 3d73b7943d Fixed public read npub 2025-07-29 18:22:18 -04:00
Daniel Gonzalez 13ad04cde1 Fixed profile npub 2025-07-29 18:15:50 -04:00
Chris Troutner f3dd9f3d07 Merge pull request #40 from Permissionless-Software-Foundation/dh-pfp
feat(nostr): Added PFPs on /content-creators page
2025-07-29 10:44:58 -07:00
Daniel Gonzalez 5e6a90f560 feat(nostr): Added PFPs on /content-creators page 2025-07-28 16:47:18 -04:00
Chris Troutner ce410ddc9c Merge pull request #39 from Permissionless-Software-Foundation/dh-like-posts
feat(nostr): Implemented 'Like' buttons functionality
2025-07-26 06:06:34 -07:00
Daniel Gonzalez 67bd90be8f feat(nostr): Implemented 'Like' buttons functionality 2025-07-25 18:01:09 -04:00
Chris Troutner 10ef73f542 Merge pull request #38 from Permissionless-Software-Foundation/dh-extra-data
feat(nostr): Implemented extra profile data
2025-07-25 06:45:44 -07:00
Chris Troutner 0ad9147e25 Merge branch 'master' into dh-extra-data 2025-07-25 06:43:08 -07:00
Chris Troutner 53c050a856 Merge pull request #37 from Permissionless-Software-Foundation/dh-feed-tabs
feat(nostr): Added tabs to Feeds page
2025-07-25 06:37:40 -07:00
Daniel Gonzalez 323f9e4b0a feat(nostr): Implemented extra profile data 2025-07-22 20:26:51 -04:00
Daniel Gonzalez ad79d61dc1 feat(nostr): Added tabs to Feeds page 2025-07-22 12:29:21 -04:00
Chris Troutner ec4a4f8c41 Merge pull request #36 from Permissionless-Software-Foundation/dh-profile-fill
feat(nostr): Fill in Nostr Profile form with existing data
2025-07-20 06:23:18 -07:00
Daniel Gonzalez c14f60a36e feat(nostr): Fill in Nostr Profile form with existing data 2025-07-19 16:25:14 -04:00
Chris Troutner 062ad933d9 Merge pull request #35 from Permissionless-Software-Foundation/dh-follow-filter
feat(nostr): Filter Creators by Follower Count
2025-07-17 10:45:52 -07:00
Daniel Gonzalez a81dbb70e1 feat(nostr): Filter Creators by Follower Count 2025-07-17 11:41:57 -04:00
Chris Troutner 638e6f9e6d Merge pull request #34 from Permissionless-Software-Foundation/ct-unstable
fix(bch-dex-lib): Fixing bug preventing NFT sales
2025-07-15 17:37:19 -07:00
Chris Troutner 09cb79abab fix(bch-dex-lib): Fixing bug preventing NFT sales 2025-07-15 17:35:54 -07:00
Chris Troutner b15f55fc69 Merge pull request #33 from Permissionless-Software-Foundation/dh-profile-changes
feat(nostr): Refactored profile page
2025-07-15 09:30:05 -07:00
Daniel Gonzalez 98f308a467 Merge branch 'master' into dh-profile-changes 2025-07-15 11:38:54 -04:00
Daniel Gonzalez 701a84b2d5 feat(nostr): Refactored profile page 2025-07-15 11:36:01 -04:00
Chris Troutner 3b8d8e6238 Merge pull request #32 from Permissionless-Software-Foundation/ct-unstable
fix(config): Using main server
2025-07-15 06:14:34 -07:00
Chris Troutner ba46ce5927 fix(config): Using main server 2025-07-15 06:14:04 -07:00
Chris Troutner 98b57ecc75 Merge pull request #31 from Permissionless-Software-Foundation/ct-unstable
Adding filters to Content Creators page
2025-07-14 19:31:33 -07:00
Chris Troutner 78ac934e70 fix(filters): Adding filters drop-down to content creators 2025-07-14 19:13:34 -07:00
Chris Troutner 1d75be5b61 fix(followers): Listing number of followers for content creators 2025-07-14 19:08:11 -07:00
Chris Troutner 98603d3fc5 Merge pull request #30 from Permissionless-Software-Foundation/dh-profile-url
feat(nostr): Added npub to Profile URL to lookup any Nostr Profile
2025-07-14 17:47:46 -07:00
Daniel Gonzalez 0f16c3e0fb feat(nostr): Added npub to Profile URL to lookup any Nostr Profile 2025-07-14 13:25:27 -04:00
Chris Troutner 43b35a3553 Merge pull request #29 from Permissionless-Software-Foundation/dh-nostr-following
feat(nostr): Added Nostr account following
2025-07-11 06:53:52 -07:00
Daniel Gonzalez 862377b929 feat(nostr): Added Nostr account following 2025-07-10 19:02:01 -04:00
Chris Troutner 02e800ff6a Merge pull request #28 from Permissionless-Software-Foundation/dh-profile-refactor
feat(nostr): Expanded profile properties
2025-07-08 16:51:23 -07:00
Daniel Gonzalez 7e1b75f965 feat(nostr): Expanded profile properties 2025-07-08 18:46:03 -04:00
Chris Troutner 990e41e8f6 Merge pull request #27 from Permissionless-Software-Foundation/dh-content-creators
feat(nostr): Created prototype for Social Media Content Creators
2025-07-06 14:36:26 -07:00
Daniel Gonzalez 9d35d80bcf feat(nostr): Created prototype for Social Media Content Creators 2025-07-05 22:05:57 -04:00
Chris Troutner 907bb7e468 Merge pull request #26 from Permissionless-Software-Foundation/ct-unstable
fix(nostr): Tagging posts with BCH address
2025-06-30 08:12:30 -07:00
Chris Troutner 7eb5ff7821 fix(nostr): Tagging posts with BCH address 2025-06-30 08:11:11 -07:00
Chris Troutner 8a982d2ef2 Merge pull request #25 from Permissionless-Software-Foundation/dh-user-data-page
fix(user-data): Reconfigured user data page
2025-06-26 06:00:58 -07:00
Daniel Gonzalez 24bb18c77c fix(user-data): Reconfigured user data page 2025-06-25 18:58:24 -04:00
Chris Troutner 913745ba9f Merge pull request #24 from Permissionless-Software-Foundation/dh-img-carousel
feat(nft): Added image carousel
2025-06-24 08:04:57 -07:00
Chris Troutner f24c199dd6 Merge branch 'master' into dh-img-carousel 2025-06-24 08:03:45 -07:00
Chris Troutner 0da9d4c567 Merge pull request #23 from Permissionless-Software-Foundation/dh-local-storage
feat(cache): Persist NFT cache to Local Storage
2025-06-24 08:02:31 -07:00
Daniel Gonzalez e05948aa32 feat(nft): Added image carousel 2025-06-23 20:20:24 -04:00
Daniel Gonzalez 3cf69cbd95 feat(cache): Persist NFT cache to Local Storage 2025-06-23 12:08:22 -04:00
Chris Troutner 45b28c168f Merge pull request #22 from Permissionless-Software-Foundation/dh-nostr-formating
feat(nostr): Added Nostr post formatting
2025-06-19 17:42:56 -07:00
Chris Troutner 3d622e9d99 Merge branch 'master' into dh-nostr-formating 2025-06-19 17:41:07 -07:00
Chris Troutner 29ce96ae92 Merge pull request #21 from Permissionless-Software-Foundation/dh-fungible-buy-btn
fix(buy): Fixed fungible buy button
2025-06-19 17:38:25 -07:00
Daniel Gonzalez a89d4c21cc feat(nostr): Added Nostr post formatting 2025-06-18 20:41:09 -04:00
Daniel Gonzalez 561ffbaaad fix(buy): Fixed fungible buy button 2025-06-18 13:45:05 -04:00
Chris Troutner 2141f919dd Merge pull request #20 from Permissionless-Software-Foundation/dh-global-feed-profiles
feat(nostr): Display profile information in Global Feed
2025-06-14 08:19:57 -07:00
Daniel Gonzalez 3087533aff feat(nostr): Display profile information in Global Feed 2025-06-13 14:21:56 -04:00
Chris Troutner 81c367dad8 Merge pull request #19 from Permissionless-Software-Foundation/ct-unstable
Ct unstable
2025-06-11 18:41:36 -07:00
Chris Troutner 5a79af5e20 fix(package-lock): fixing package-lock 2025-06-11 18:40:03 -07:00
Chris Troutner 3eb322e9c8 Merge branch 'master' into ct-unstable 2025-06-11 18:25:13 -07:00
Chris Troutner da3d24abaa Merge pull request #18 from Permissionless-Software-Foundation/dh-fungible-tokens
feat(tokens): Added support for fungible tokens
2025-06-11 18:24:58 -07:00
Chris Troutner ae2ba02adb fix(nav): Organizing nav menu 2025-06-11 18:22:19 -07:00
Daniel Gonzalez 7c08a281b5 linting 2025-06-10 19:49:28 -04:00
Daniel Gonzalez e7bbd7f7b4 feat(tokens): Added support for fungible tokens 2025-06-10 19:46:37 -04:00
Chris Troutner db179b3c6c Merge branch 'master' into ct-unstable 2025-06-09 17:11:56 -07:00
Chris Troutner a93241f880 Merge pull request #17 from Permissionless-Software-Foundation/dh-global-feed
feat(nostr): Created Nostr Global Feed page
2025-06-09 17:11:38 -07:00
Chris Troutner 33a3a21b68 Comments 2025-06-09 17:09:38 -07:00
Daniel Gonzalez 76dcd45d16 feat(nostr): Created Nostr Global Feed page 2025-06-09 19:32:55 -04:00
Chris Troutner acb39ceff8 Merge pull request #15 from Permissionless-Software-Foundation/dh-cache-data
feat(cache): caching mutable data
2025-06-06 20:38:34 -07:00
Daniel Gonzalez 63b7bad1d1 Fixed merge conflicts 2025-06-06 21:24:09 -04:00
Chris Troutner c140255a26 Merge pull request #16 from Permissionless-Software-Foundation/dh-product-modal
feat(info): Added product info modal
2025-06-06 11:23:23 -07:00
Daniel Gonzalez 947cb43535 linting 2025-06-06 12:53:18 -04:00
Daniel Gonzalez 93980ed703 feat(info): Added product info modal 2025-06-06 12:51:44 -04:00
Daniel Gonzalez 36c81e7ce9 feat(cache): caching mutable data 2025-06-03 19:34:57 -04:00
Chris Troutner 6ad3d40591 Merge pull request #14 from Permissionless-Software-Foundation/dh-profile-style
feat(nostr): Initial Styling of Nostr Profile
2025-06-02 10:37:52 -07:00
Chris Troutner a4027b2fcb feat(tagged posts): Nostr posts made with this app are tagged for easy tracking in a global feed 2025-06-02 10:36:35 -07:00
Daniel Gonzalez 192497b5cf feat(nostr): Initial Styling of Nostr Profile 2025-06-02 12:19:43 -04:00
Chris Troutner fda2de5362 Merge pull request #13 from Permissionless-Software-Foundation/dh-kind1-posts
feat(nostr): Created UI for Kind 1 Nostr posts
2025-05-27 18:25:14 -07:00
Daniel Gonzalez 131720ed58 feat(nostr): Created UI for Kind 1 Nostr posts 2025-05-27 19:25:38 -04:00
Chris Troutner 64b1aec4cf Merge pull request #12 from Permissionless-Software-Foundation/dh-nostr-keys
fix(nostr): Display Nostr keys as npub and nsec
2025-05-27 08:25:18 -07:00
Daniel Gonzalez 70ce941eba fix(nostr): Display Nostr keys as npub and nsec 2025-05-26 18:47:31 -04:00
Chris Troutner dfd5934ddb Merge pull request #11 from Permissionless-Software-Foundation/dh-nostr-views
feat(nostr): Created nostr views
2025-05-25 07:46:57 -07:00
Chris Troutner e0652714e8 Merge branch 'ct-unstable' into dh-nostr-views 2025-05-25 07:43:03 -07:00
Chris Troutner 838a9e3dfd Adding LLM MD summary 2025-05-24 08:41:59 -07:00
Daniel Gonzalez 6b9626aeb1 feat(nostr): Created nostr views 2025-05-23 18:41:05 -04:00
Chris Troutner 3ce258fd8c Merge branch 'master' into ct-unstable 2025-05-22 08:18:07 -07:00
Chris Troutner 10aef2a397 Merge pull request #10 from Permissionless-Software-Foundation/dh-nsec-npub
feat(nostr): Generate Nostr nsec and npub
2025-05-22 08:17:47 -07:00
Daniel Gonzalez a77147d077 feat(nostr): Generate Nostr nsec and npub 2025-05-21 14:59:10 -04:00
Chris Troutner aa7d6f4bc7 using config value 2025-05-19 16:55:44 -07:00
Chris Troutner 054205c545 Fixing issue with sweeping funds 2025-05-19 16:48:47 -07:00
Chris Troutner 686d51615a fix(config): Moving nostr relay and topic to config file 2025-05-19 16:41:39 -07:00
Chris Troutner 55b87d4e9d Merge pull request #9 from Permissionless-Software-Foundation/ct-unstable
Testing Semantic Release
2025-04-11 06:46:10 -07:00
Chris Troutner 2208a1132f fix(nostr): fixing linting rule 2025-04-11 06:42:34 -07:00
Chris Troutner 10d47954cf fix(README): Linking to Seller wallet app 2025-04-11 06:38:38 -07:00
Chris Troutner 3b23edf758 Merge pull request #8 from Permissionless-Software-Foundation/ct-unstable
Syncing with upstream bch-wallet-web3-spa repository
2025-04-10 19:43:52 -07:00
Chris Troutner 99a299c2e3 fix(bch-wallet-web3-spa): Syncing with upstream repo 2025-04-10 19:42:38 -07:00
Chris Troutner ab02a66058 Merge pull request #14 from Permissionless-Software-Foundation/ct-unstable
Adding Configuration page
2025-04-10 15:11:26 -07:00
Chris Troutner 37e7061208 fix(footer): Updating nav and footer 2025-04-10 15:10:11 -07:00
Chris Troutner 9bf61f5913 fix(react-bootstrap-web3-spa): Syncing with upstream repo 2025-04-10 15:06:55 -07:00
Chris Troutner 754f10a61b Merge pull request #37 from Permissionless-Software-Foundation/dh-config-view
fear(config): Changed server selection view to configuration view
2025-04-10 14:18:10 -07:00
Chris Troutner fcd3da20f6 Edits to configuration view 2025-04-10 14:16:47 -07:00
Daniel Gonzalez 7ee7d7ec1b fear(config): Changed server selection view to configuration view 2025-04-10 10:56:44 -04:00
Chris Troutner 634ec7836c Merge pull request #13 from Permissionless-Software-Foundation/ct-unstable
Syncing with upstream react-boostrap-web3-spa
2025-04-04 07:55:37 -07:00
Chris Troutner 688ea3fe71 Syncing with upstream react-bootstrap-web3-spa 2025-04-04 07:50:10 -07:00
Chris Troutner 91952761f9 Merge pull request #36 from Permissionless-Software-Foundation/ct-unstable
Adding semantic release
2025-04-04 07:37:01 -07:00
Chris Troutner a22c29cbce Fixing test script so that it exits 2025-04-04 07:29:57 -07:00
Chris Troutner 25ba833ab9 fix(semantic-release): Adding husky and semantic release as dev dependencies 2025-04-04 07:03:37 -07:00
Chris Troutner 7ce2a0878b Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-03-29 13:15:14 -07:00
Chris Troutner 5826313c01 Merge pull request #12 from Permissionless-Software-Foundation/dh-sign-view
feat(sign): Ported Sign view
2025-03-25 17:07:56 -07:00
Daniel Gonzalez 3c0fe9ab00 Fixing forms enter key press issue 2025-03-24 18:43:15 -04:00
Daniel Gonzalez 165141ea41 feat(sign): Ported Sign view 2025-03-24 18:08:27 -04:00
Chris Troutner 0951385872 Merge pull request #7 from Permissionless-Software-Foundation/ct-unstable
Adding sweep functionality to cancel buy order
2025-03-22 05:39:32 -07:00
Chris Troutner b0487d78e9 fix(sweep): Added button to sweep DEX offers 2025-03-22 05:37:44 -07:00
Chris Troutner 2766867b26 Syncing with upstream, fixing merge conflicts 2025-03-22 05:18:01 -07:00
Chris Troutner 37a124a3d3 Merge pull request #11 from Permissionless-Software-Foundation/dh-sweep-view
feat(sweep): Ported the Sweep view
2025-03-21 07:10:47 -07:00
Daniel Gonzalez a3f75af1d9 package.json fix 2025-03-20 19:50:17 -04:00
Daniel Gonzalez 0d78498e4d feat(sweep): Ported the Sweep view 2025-03-20 19:48:14 -04:00
Chris Troutner 051c600e9d Merge pull request #6 from Permissionless-Software-Foundation/ct-unstable
fix(buy button): Improved modal messages
2025-03-16 06:47:09 -07:00
Chris Troutner 28c515ea9b fix(buy button): Improved modal messages 2025-03-16 06:46:08 -07:00
Chris Troutner 82b86724eb Merge pull request #5 from Permissionless-Software-Foundation/ct-unstable
Syncing with upstream repos
2025-03-15 09:02:12 -07:00
Chris Troutner e8b8492f35 Syncing with upstream, fixing merge conflicts 2025-03-15 09:01:09 -07:00
Chris Troutner 098f5e300e Merge pull request #10 from Permissionless-Software-Foundation/ct-unstable
fix(menu): Moving tokens in nav menu above wallet
2025-03-15 08:47:59 -07:00
Chris Troutner 424cc3069b fix(menu): Moving tokens in nav menu above wallet 2025-03-15 08:47:27 -07:00
Chris Troutner b3cebcf13e Merge pull request #9 from Permissionless-Software-Foundation/ct-unstable
Making BCH page the default at startup
2025-03-15 08:30:51 -07:00
Chris Troutner 34bdd9ba87 Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-03-15 08:30:01 -07:00
Chris Troutner d28f26e44d Merge pull request #35 from Permissionless-Software-Foundation/ct-unstable
fix(footer): Removing links slowing down startup
2025-03-15 08:29:20 -07:00
Chris Troutner c7e55daeda fix(footer): Removing links slowing down startup 2025-03-15 08:28:21 -07:00
Chris Troutner 6de9ad944c fix(menu): Making BCH page the default startup page 2025-03-15 08:23:17 -07:00
Chris Troutner 75b5169390 Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-03-14 12:43:12 -07:00
Chris Troutner 02367ce10f Merge pull request #4 from Permissionless-Software-Foundation/dh-buy-modal
feat(buy): Added buy modal to DEX taker wallet
2025-03-12 08:10:06 -07:00
Daniel Gonzalez d5e9e15909 feat(buy): Added buy modal to DEX taker wallet 2025-03-12 10:57:03 -04:00
Chris Troutner a689a9b3e6 Merge pull request #8 from Permissionless-Software-Foundation/ct-unstable
Ct unstable
2025-03-11 08:13:39 -07:00
Chris Troutner 40911990ac fixing sync conflict 2025-03-11 08:10:31 -07:00
Chris Troutner 88198f28fe Merge pull request #7 from Permissionless-Software-Foundation/dh-server-list
fix(server): Ensuring server list is available on network error
2025-03-11 07:31:38 -07:00
Chris Troutner 42d5ce7894 Merge pull request #34 from Permissionless-Software-Foundation/dh-server-list
fix(server): Ensuring server list is available on network error
2025-03-11 07:30:51 -07:00
Daniel Gonzalez f2e94ae605 fix(server): Ensuring server list is available on network error 2025-03-10 16:27:10 -04:00
Daniel Gonzalez 0e71568750 fix(server): Ensuring server list is available on network error 2025-03-10 16:26:09 -04:00
Chris Troutner 5ceb7e8e87 Merge pull request #3 from Permissionless-Software-Foundation/ct-unstable
Added Buy functionality
2025-03-09 08:29:44 -07:00
Chris Troutner 927b073557 Got nostr-tools to install without dep errors 2025-03-05 20:43:41 -08:00
Chris Troutner 80f95ee576 Got counteroffer to post to Nostr 2025-03-05 20:26:31 -08:00
Chris Troutner 138c48559f Created buy button without any logic 2025-03-05 19:10:55 -08:00
Chris Troutner 77d83349c1 Added bch-dex-lib and Nostr libraries 2025-03-05 19:03:14 -08:00
Chris Troutner 1b16f62b99 Merge pull request #2 from Permissionless-Software-Foundation/nft-card
feat(NFT): Lazy-load NFT token icons
2025-03-03 18:57:05 -08:00
Daniel Gonzalez ab38407686 feat(NFT): Lazy-load NFT token icons 2025-03-03 18:02:07 -04:00
Chris Troutner 2d9fb823cd syncing with upstream 2025-02-17 08:45:39 -07:00
Chris Troutner 3c4d3c8a62 Syncing with upstream, fixing merge conflicts 2025-02-17 08:00:02 -07:00
Chris Troutner 717a4c27a8 Merge pull request #33 from Permissionless-Software-Foundation/dh-persist-server
fix(server): Persist server through refresh
2025-02-16 11:11:14 -07:00
Daniel Gonzalez 62903ccdcb fix(server): Persist server through refresh 2025-02-15 20:55:43 -04:00
Chris Troutner 6794fb8644 Syncingn with upstream, bring in SLP page 2025-02-11 08:17:58 -07:00
Chris Troutner 678e90cef0 Adding debugging 2025-02-11 08:16:44 -07:00
Chris Troutner f6e362c5ff Merge pull request #6 from Permissionless-Software-Foundation/dh-token-view
feat(tokens): Ported slp-token View
2025-02-11 08:15:51 -07:00
Daniel Gonzalez 0328f8b34b Fixed clipboard paste on send token modal 2025-02-10 20:02:48 -04:00
Daniel Gonzalez f4c17322dc feat(tokens): Ported slp-token View 2025-02-10 19:51:19 -04:00
Chris Troutner b3e71b0353 Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-02-09 10:28:17 -07:00
Chris Troutner f3cbb91927 Handling errors when downloading mutable data 2025-02-09 10:28:08 -07:00
Chris Troutner 89ffde9f39 Merge remote-tracking branch 'upstream/master' 2025-02-09 10:27:22 -07:00
Chris Troutner c16ae834a5 Updating minimal-slp-wallet 2025-02-09 10:26:54 -07:00
Chris Troutner a70e995027 syncing with upstream 2025-02-09 09:59:13 -07:00
Chris Troutner d1d86cba9c Sycning with upstream 2025-02-09 09:57:56 -07:00
Chris Troutner 9f4d82d440 Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-02-09 09:56:19 -07:00
Chris Troutner 946e66c45b Merge pull request #32 from Permissionless-Software-Foundation/ct-unstable
fix(minimal-slp-wallet): Updating to v5.13.1
2025-02-09 09:55:46 -07:00
Chris Troutner 71637e5ea3 fix(minimal-slp-wallet): Updating to v5.13.1 2025-02-09 09:55:00 -07:00
Chris Troutner d322ef16bb Merge pull request #1 from Permissionless-Software-Foundation/origin/ct-unstable
Adding Nostr polyfills
2025-02-09 08:54:11 -07:00
Chris Troutner 83f46c0297 Merge pull request #5 from Permissionless-Software-Foundation/ct-unstable
fix(minimal-slp-wallet): Updating to v5.13.0
2025-02-09 08:52:46 -07:00
Chris Troutner a0189c2de9 Merge remote-tracking branch 'upstream/master' into ct-unstable 2025-02-09 08:51:38 -07:00
Chris Troutner eddd454ee0 Merge pull request #31 from Permissionless-Software-Foundation/ct-unstable
fix(minimal-slp-wallet): Updating to v5.13.0
2025-02-09 08:45:25 -07:00
Chris Troutner 829c6e226c fix(minimal-slp-wallet): Updating to v5.13.0 2025-02-09 08:44:06 -07:00
Chris Troutner 55d608a7bf thinking through best way to retrieve token data 2025-02-09 04:05:18 -07:00
Chris Troutner f064c7900e fix(bch-dex-lib): Added lib and required polyfills 2025-02-07 11:28:08 -07:00
Chris Troutner d88d12d486 Syncing with upstream 2025-02-06 05:43:59 -07:00
Chris Troutner 5dccc1398f Merge pull request #4 from Permissionless-Software-Foundation/dh-send-view
feat(send): Ported bch-send View
2025-02-06 05:39:15 -07:00
Chris Troutner 77c19e60b5 minor tweeks 2025-02-06 05:28:59 -07:00
Daniel Gonzalez 996f83f1a8 feat(send): Ported bch-send View 2025-02-05 19:58:54 -04:00
Chris Troutner dc4a7f3501 Got token cards to display on NFT page 2025-02-04 19:08:14 -07:00
Chris Troutner 9f4f972880 Got async startup funcs for NFT page 2025-02-04 17:14:47 -07:00
Chris Troutner 7981c7fb1f fix(NFTs): Creating NFTs page 2025-02-04 17:02:54 -07:00
62 changed files with 28025 additions and 5021 deletions
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+1 -1
View File
@@ -2,7 +2,7 @@
This is a web-based single page app (SPA) written in React. It is forked from [bch-wallet-web3-spa](https://github.com/Permissionless-Software-Foundation/bch-wallet-web3-spa) and replaces [bch-dex-taker-ui](https://github.com/Permissionless-Software-Foundation/bch-dex-taker-ui).
The purpose of this app is to provide a web-based interface for users to browse NFT and fungible tokens for sale on the SLP DEX protocol. This provides a simple 1-click purchase functionality. A different UI is used for selling tokens, as there are more requirements for sellers imposed by the SWaP protocol.
The purpose of this app is to provide a web-based interface for users to browse NFT and fungible tokens for sale on the SLP DEX protocol. This provides a simple 1-click purchase functionality. A different UI ([bch-dex-ui-v3](https://github.com/Permissionless-Software-Foundation/bch-dex-ui-v3)) is used for selling tokens, as there are more requirements for sellers imposed by the SWaP protocol.
## Installation
```bash
+18
View File
@@ -0,0 +1,18 @@
const webpack = require('webpack')
module.exports = function override (config) {
config.resolve.fallback = {
...config.resolve.fallback,
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
vm: require.resolve('vm-browserify')
}
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser'
})
])
return config
}
File diff suppressed because it is too large Load Diff
+16337 -4818
View File
File diff suppressed because it is too large Load Diff
+37 -6
View File
@@ -7,23 +7,37 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "^1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"crypto-browserify": "3.12.1",
"marked": "15.0.12",
"nostr-tools": "2.10.4",
"p2wdb": "2.4.1",
"process": "0.11.10",
"qrcode.react": "4.2.0",
"query-string": "7.1.1",
"react": "19.0.0",
"react-bootstrap": "2.10.7",
"react-bs-datatable": "3.15.0",
"react-dom": "19.0.0",
"react-markdown": "10.1.0",
"react-router-dom": "7.1.3",
"react-scripts": "5.0.1",
"stream-browserify": "3.0.0",
"use-local-storage-state": "19.5.0",
"use-query-params": "1.2.3"
"use-query-params": "1.2.3",
"vm-browserify": "1.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "npm run lint",
"eject": "react-app-rewired eject",
"lint": "standard --env mocha --fix",
"pub": "node deploy/publish-main.js",
"pub:ghp": "./deploy/publish-gh-pages.sh"
@@ -44,8 +58,25 @@
]
},
"devDependencies": {
"minimal-slp-wallet": "5.8.5",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"husky": "9.1.7",
"minimal-slp-wallet": "5.13.1",
"react-app-rewired": "2.2.1",
"semantic-release": "24.2.3",
"standard": "17.0.0",
"web3.storage": "4.3.0"
},
"release": {
"publish": [
{
"path": "@semantic-release/npm",
"npmPublish": false
}
]
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BCH Web3 Template</title>
<title>SLP DEX Buyer Wallet</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+1 -1
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -31,3 +31,28 @@
padding: 0.5rem;
}
#address-switch {
cursor: pointer;
}
.buy-modal {
top: 30%!important;
width: auto!important;
}
.cursor-pointer {
cursor: pointer;
}
/* Remove input number arrows Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Remove input number arrows Firefox */
input[type=number] {
--moz-appearance: textfield;
}
+56 -3
View File
@@ -14,6 +14,10 @@ import NavMenu from './components/nav-menu'
import useAppState from './hooks/state'
import { UninitializedView, InitializedView } from './components/starter-views'
// Paths with custom async loader
// Single Views, load minimal async services in order to prevent the long-time screen loader.
const sigleViewPaths = ['/profile', 'user-data']
function App (props) {
// Load all the app state into a single object that can be passed to child
// components.
@@ -30,12 +34,47 @@ function App (props) {
})
}, [])
const isSignleView = useCallback(async () => {
// Get current path
const currentPath = window.location.pathname
// Get hash from url
const hash = window.location.hash
const allowedPath = sigleViewPaths.find((val) => { return currentPath.match(val) })
if (hash === '#single-view' && allowedPath) {
addToModal('Loading minimal-slp-wallet', appData)
const asyncLoad = new AsyncLoad()
if (!appData.wallet) {
await asyncLoad.loadWalletLib()
const walletTemp = await asyncLoad.initStarterWallet(appData.serverUrl, appData.lsState.mnemonic, appData)
appData.setWallet(walletTemp)
// Get the BCH spot price
addToModal('Getting BCH spot price in USD', appData)
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
}
// Update Modal State
appData.setIsSingleView(true)
appData.setHideSpinner(true)
appData.setShowStartModal(false)
appData.setDenyClose(false)
/* // Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(false) */
return true
}
return false
}, [appData, addToModal])
/** Load all required data before component start. */
useEffect(() => {
async function asyncEffect () {
console.log('asyncInitStarted: ', appData.asyncInitStarted)
const singleView = await isSignleView()
if (!appData.asyncInitStarted) {
console.log('asyncInitStarted: ', appData.asyncInitStarted)
console.log('is single view : ', singleView)
if (!appData.asyncInitStarted && !singleView) {
try {
// Instantiate the async load object.
const asyncLoad = new AsyncLoad()
@@ -71,6 +110,20 @@ function App (props) {
addToModal('Getting BCH spot price in USD', appData)
await asyncLoad.getUSDExchangeRate(walletTemp, appData.updateBchWalletState, appData)
// Load the P2WDB libraries.
addToModal('Loading P2WDB Libraries', appData)
const { p2wdbRead, p2wdbWrite } = asyncLoad.getP2WDBLib({ bchWallet: walletTemp })
// Load the DEX library.
addToModal('Loading DEX Library', appData)
const dexLib = asyncLoad.getDexLib({ bchWallet: walletTemp, p2wdbRead, p2wdbWrite })
appData.setDexLib(dexLib)
// Load the Nostr library.
addToModal('Loading Nostr Library', appData)
const nostrLib = asyncLoad.getNostrLib({ bchWallet: walletTemp })
appData.setNostr(nostrLib)
// Update state
appData.setShowStartModal(false)
appData.setDenyClose(false)
@@ -98,7 +151,7 @@ function App (props) {
}
}
asyncEffect()
}, [appData, addToModal])
}, [appData, addToModal, isSignleView])
return (
<>
+4 -3
View File
@@ -14,7 +14,8 @@ function GetBalance (props) {
const [textInput, setTextInput] = useState('')
// Button click handler
const handleGetBalance = async (event) => {
const handleGetBalance = async (e) => {
e.preventDefault()
try {
// Exit on invalid input
if (!textInput) return
@@ -43,9 +44,9 @@ function GetBalance (props) {
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Form>
<Form onSubmit={handleGetBalance}>
<Form.Group className='mb-3' controlId='formBasicEmail'>
<Form.Label>Enter a BCH address to check the balance.</Form.Label>
<Form.Label>Enter any BCH address to query its balance on the blockchain.</Form.Label>
<Form.Control type='text' placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' onChange={e => setTextInput(e.target.value)} />
</Form.Group>
@@ -0,0 +1,53 @@
/*
This card displays the users balance in BCH.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCoins } from '@fortawesome/free-solid-svg-icons'
const BalanceCard = (props) => {
const { appData } = props
const bchjs = appData.wallet.bchjs
const sats = appData.bchWalletState.bchBalance
const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats)
const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice)
return (
<>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2><FontAwesomeIcon icon={faCoins} size='lg' /> Balance</h2>
</Card.Title>
<br />
<Container>
<Row>
<Col>
<b>USD</b>: ${usdBalance}
</Col>
</Row>
<Row>
<Col>
<b>BCH</b>: {bchBalance}
</Col>
</Row>
<Row>
<Col>
<b>Satoshis</b>: {sats}
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</>
)
}
export default BalanceCard
+64
View File
@@ -0,0 +1,64 @@
/*
This View allows sending and receiving of BCH
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import RefreshBchBalanceButton from './refresh-bch-balance-button'
import SendCard from './send-card'
import BalanceCard from './balance-card'
import ReceiveCard from './receive-card'
// Working array for storing modal output.
// this.modalBody = []
function BchSend ({ appData }) {
return (
<>
<Container>
<Row>
<Col xs={6}>
<RefreshBchBalanceButton
appData={appData}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<a href='https://youtu.be/KN1ZMWoLoGs' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<BalanceCard appData={appData} />
</Col>
</Row>
<br />
<Row>
<Col>
<SendCard
appData={appData}
/>
</Col>
</Row>
<br />
<Row>
<Col>
<ReceiveCard appData={appData} />
</Col>
</Row>
</Container>
</>
)
}
export default BchSend
@@ -0,0 +1,93 @@
/*
This card displays the users BCH and SLP address and QR code
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Card, Form } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWallet } from '@fortawesome/free-solid-svg-icons'
import { QRCodeSVG } from 'qrcode.react'
const ReceiveCard = ({ appData }) => {
const [addrSwitch, setAddrSwitch] = useState(false)
const [displayCopyMsg, setDisplayCopyMsg] = useState(false)
// Determine which address to display
const addrToDisplay = !addrSwitch
? appData.bchWalletState.cashAddress
: appData.bchWalletState.slpAddress
// Copy the selected address to the clipboard when the QR image is clicked
const handleCopyAddress = async (value) => {
appData.appUtil.copyToClipboard(value)
// Display the copied message
setDisplayCopyMsg(true)
// Clear the copied message after some time
setTimeout(() => {
setDisplayCopyMsg(false)
}, 1000)
}
// Event handler for address switch toggle
const handleAddrSwitchToggle = (event) => {
setAddrSwitch(event.target.checked)
}
return (
<>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
<Card.Title>
<h2><FontAwesomeIcon icon={faWallet} size='lg' /> Receive</h2>
</Card.Title>
<br />
<Container>
<Row>
<Col style={{ color: 'green', marginBottom: '20px' }}>
{displayCopyMsg ? 'Copied' : null}
</Col>
</Row>
<Row>
<Col>
<QRCodeSVG
style={{ cursor: 'pointer' }}
className='qr-code'
value={addrToDisplay}
size={256}
fgColor='#333'
onClick={() => { handleCopyAddress(addrToDisplay) }}
/>
</Col>
</Row>
<Row>
<Col style={{ marginTop: '20px' }}>
<p>{addrToDisplay}</p>
</Col>
</Row>
<Row>
<Col xs={4} />
<Col xs={4}>
<Form>
<Form.Check
type='switch'
id='address-switch'
onChange={e => handleAddrSwitchToggle(e)}
/>
</Form>
</Col>
<Col xs={4} />
</Row>
</Container>
</Card.Body>
</Card>
</>
)
}
export default ReceiveCard
@@ -0,0 +1,90 @@
/*
This library exports a RefreshBalance functional Component and a
refreshBalance() function.
The RefreshBalance Component is rendered as a hidden Waiting modal.
When the refreshBalance() function is called, it causes the modal to
appear while the wallet balance is updated. Once updated, the modal is hidden
again.
*/
// Global npm libraries
import React, { useEffect, useState, useCallback } from 'react'
// Local libraries
import WaitingModal from '../../waiting-modal'
export default function RefreshBchBalance (props) {
// Dependency injections of props
const { ref } = props
// State
const [showWaitingModal, setShowWaitingModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner] = useState(false)
// Add a new line to the waiting modal.
const addToModal = useCallback((inStr) => {
// console.log('addToModal() inStr: ', inStr)
setModalBody(prevBody => {
// console.log('prevBody: ', prevBody)
prevBody.push(inStr)
return prevBody
})
}, [])
// Update the balance of the wallet.
const handleRefreshBalance = useCallback(async (appData) => {
try {
setModalBody([])
// Throw up the waiting modal
setShowWaitingModal(true)
addToModal('Updating wallet balance...')
// Get handles on app data.
const walletState = appData.bchWalletState
const cashAddr = appData.bchWalletState.cashAddress
const wallet = appData.wallet
// Get the latest balance of the wallet.
const newBalance = await wallet.getBalance({ bchAddress: cashAddr })
addToModal('Updating BCH per USD price...')
const bchUsdPrice = await wallet.getUsd()
// Update the wallet state.
walletState.bchBalance = newBalance
walletState.bchUsdPrice = bchUsdPrice
appData.updateBchWalletState({ walletState, appData })
setShowWaitingModal(false)
setModalBody([])
} catch (err) {
console.error('Error while trying to update BCH balance: ', err)
addToModal([`Error: ${err.message}`])
setShowWaitingModal(false)
}
}, [addToModal])
// add a ref to the handleRefreshBalance function
// This is used to call the function from the parent component.
useEffect(() => {
if (ref && !ref.current) ref.current = { handleRefreshBalance }
}, [ref, handleRefreshBalance])
return (
<>
<>
{showWaitingModal && (
<WaitingModal
heading='Refreshing BCH Balance'
body={modalBody}
hideSpinner={hideSpinner}
/>
)}
</>
</>
)
}
@@ -0,0 +1,40 @@
/*
This component is displayed as a button. When clicked, it loads the
RefreshBchBalance component, which renders a waiting modal while the wallet
balance is refreshed.
*/
// Global npm libraries
import React, { useRef } from 'react'
import { Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import RefreshBchBalance from './refresh-balance'
function RefreshBchBalanceButton (props) {
// Dependency injections of props
const appData = props.appData
// Child function references
const refreshBchBalanceRef = useRef()
// Update the balance of the wallet.
async function handleButtonRefreshBalance (appData) {
// Call the child function
refreshBchBalanceRef.current.handleRefreshBalance(appData)
}
return (
<>
<Button variant='success' onClick={() => { handleButtonRefreshBalance(appData) }}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
</>
)
}
export default RefreshBchBalanceButton
@@ -0,0 +1,332 @@
/*
This component controls sending of BCH.
*/
// Global npm libraries
import React, { useState, useRef } from 'react'
import { Container, Row, Col, Card, Form, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane, faPaste, faRandom } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import WaitingModal from '../../waiting-modal'
import RefreshBchBalance from './refresh-balance'
function SendCard (props) {
// Dependency injection through props
const appData = props.appData
// Modal State
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [hideWaitingModal, setHideWaitingModal] = useState(true)
const [hideModal, setHideModal] = useState(true)
// Form State
const [bchAddr, setBchAddr] = useState('')
const [amountStr, setAmountStr] = useState('')
const [amountUnits, setAmountUnits] = useState('USD')
const [oppositeUnits, setOppositeUnits] = useState('BCH')
const [oppositeQty, setOppositeQty] = useState(0)
// Child function references
const refreshBchBalanceRef = useRef()
// Update the balance of the wallet.
async function handleButtonRefreshBalance (appData) {
// Call the child function
refreshBchBalanceRef.current.handleRefreshBalance(appData)
}
// Encapsulate the state for this component into a single object that can
// be passed around to subfunctions and subcomponents.
const sendCardData = {
modalBody,
setModalBody,
hideSpinner,
setHideSpinner,
hideWaitingModal,
setHideWaitingModal,
hideModal,
setHideModal,
bchAddr,
setBchAddr,
amountStr,
setAmountStr,
amountUnits,
setAmountUnits,
oppositeUnits,
setOppositeUnits,
oppositeQty,
setOppositeQty
}
// This function is called when the modal is closed.
function onModalClose () {
sendCardData.setHideModal(true)
handleButtonRefreshBalance(appData)
}
async function pasteFromClipboard () {
try {
const addr = await appData.appUtil.readFromClipboard()
sendCardData.setBchAddr(addr)
} catch (err) {
// Browser implementation. Exit quietly.
}
}
// This is an on-change event handler that updates the amount calculated in
// both BCH and USD as the user types.
function handleUpdateAmount (inObj = {}) {
try {
const { event, appData, sendCardData } = inObj
// Update the state of the text box.
let amountStr = event.target.value
sendCardData.setAmountStr(amountStr)
if (!amountStr) amountStr = '0'
// Convert the string to a number.
const amountQty = parseFloat(amountStr)
const bchUsdPrice = appData.bchWalletState.bchUsdPrice
const bchjs = appData.wallet.bchjs
// Initialize local variables
let oppositeQty = 0
// const amountUsd = 0
// const amountBch = 0
// Calculate the amount in the opposite units.
const currentUnit = sendCardData.amountUnits
if (currentUnit.includes('USD')) {
// Convert USD to BCH
oppositeQty = bchjs.Util.floor8(amountQty / bchUsdPrice)
// amountUsd = amountQty
// amountBch = oppositeQty
} else {
// Convert BCH to USD
oppositeQty = bchjs.Util.floor2(amountQty * bchUsdPrice)
// amountUsd = oppositeQty
// amountBch = amountQty
}
// Update app state
sendCardData.setOppositeQty(oppositeQty)
} catch (err) {
/* exit quietly */
console.log('Error: ', err)
}
}
// This is a click event handler that toggles the units between BCH and USD.
function handleSwitchUnits ({ sendCardData }) {
// Toggle the unit
let newUnit = ''
let oppositeUnits = ''
const oldUnit = sendCardData.amountUnits
if (oldUnit.includes('USD')) {
newUnit = 'BCH'
oppositeUnits = 'USD'
} else {
newUnit = 'USD'
oppositeUnits = 'BCH'
}
// Clear the Amount text box
sendCardData.setAmountStr('')
sendCardData.setOppositeQty(0)
// Persist the new units.
sendCardData.setAmountUnits(newUnit)
sendCardData.setOppositeUnits(oppositeUnits)
}
// Add a new line to the waiting modal.
function addToModal (inStr, sendCardData) {
sendCardData.setModalBody(prevBody => {
prevBody.push(inStr)
return prevBody
})
}
// Send BCH based to the address in the form, and the amount specified in the
// form.
async function handleSendBch ({ sendCardData, appData }) {
console.log('Sending BCH')
try {
// Clear the modal body
sendCardData.setModalBody([])
sendCardData.setHideSpinner(false)
// Open the modal
sendCardData.setHideModal(false)
let amountBch
if (sendCardData.amountUnits === 'USD') {
amountBch = sendCardData.oppositeQty
} else {
amountBch = parseFloat(sendCardData.amountStr)
}
console.log('amountBch: ', amountBch)
if (amountBch < 0.00000546) throw new Error('Trying to send less than dust.')
let bchAddr = sendCardData.bchAddr
let infoStr = `Sending ${amountBch} BCH ($${sendCardData.amountUsd} USD) to ${bchAddr}`
console.log(infoStr)
// Update modal
addToModal('Preparing to send bch...', sendCardData)
const wallet = appData.wallet
const bchjs = wallet.bchjs
// If the address is an SLP address, convert it to a cash address.
if (bchAddr.includes('simpleledger:')) {
bchAddr = bchjs.SLP.Address.toCashAddress(bchAddr)
}
// Convert the BCH to satoshis
const sats = bchjs.BitcoinCash.toSatoshi(amountBch)
// Update the wallets UTXOs
infoStr = 'Updating UTXOs...'
console.log(infoStr)
addToModal(infoStr, sendCardData)
await wallet.getUtxos()
const receivers = [{
address: bchAddr,
amountSat: sats
}]
const txid = await wallet.send(receivers)
// Display TXID
infoStr = `txid: ${txid}`
// console.log(infoStr)
// modalBody.push(infoStr)
addToModal(infoStr, sendCardData)
// Link to block explorer
const explorerUrl = `https://blockchair.com/bitcoin-cash/transaction/${txid}`
const explorerLink = (<a href={`${explorerUrl}`} target='_blank' rel='noreferrer'>Block Explorer</a>)
// modalBody.push(explorerLink)
addToModal(explorerLink, sendCardData)
sendCardData.setHideSpinner(true)
sendCardData.setBchAddr('')
sendCardData.setAmountStr('')
} catch (err) {
console.log('Error in handleSendBch(): ', err)
sendCardData.setModalBody([`Error: ${err.message}`])
sendCardData.setHideSpinner(true)
}
}
return (
<>
{
hideModal
? null
: (<WaitingModal
heading='Sending BCH'
body={modalBody}
hideSpinner={hideSpinner}
closeFunc={onModalClose}
closeModalData={{ appData, sendCardData }}
/>)
}
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send</h2>
</Card.Title>
<br />
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<b>BCH Address:</b>
</Col>
</Row>
<Row>
<Col xs={12} style={{ textAlign: 'center' }}>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group controlId='formBasicEmail' style={{ display: 'flex', alignItems: 'center' }}>
<Form.Control
style={{ marginRight: '1rem' }}
type='text'
placeholder='bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
onChange={e => setBchAddr(e.target.value)}
value={bchAddr}
/>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={faPaste}
size='lg'
onClick={(e) => pasteFromClipboard()}
/>
</Form.Group>
</Form>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<b>Amount:</b>
</Col>
</Row>
<Row>
<Col xs={12}>
<Form style={{ paddingBottom: '10px' }} onSubmit={(e) => { e.preventDefault(); handleSendBch({ sendCardData, appData }) }}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='number'
onChange={(event) => handleUpdateAmount({ event, appData, sendCardData })}
value={amountStr}
/>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col xs={6}>
<strong>Units</strong> : {amountUnits}
<FontAwesomeIcon
style={{ cursor: 'pointer', marginLeft: '5px' }}
icon={faRandom}
size='lg'
onClick={(e) => handleSwitchUnits({ sendCardData, appData })}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<strong>{oppositeUnits}</strong> : {oppositeQty}
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={(e) => handleSendBch({ sendCardData, appData })}>Send</Button>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</>
)
}
export default SendCard
@@ -77,7 +77,7 @@ const WalletImport = (props) => {
<Container>
<Row>
<Col xs={12} className='text-break' style={{ textAlign: 'center' }}>
<Form>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group className='mb-3' controlId='formImportWallet' style={{ display: 'flex', alignItems: 'center' }}>
<Form.Control
style={{ margin: '1rem' }}
@@ -16,13 +16,16 @@ import CopyOnClick from './copy-on-click'
function WalletSummary (props) {
// Props
const appData = props.appData
const bchWalletState = appData.bchWalletState
console.log('wallet summary state: ', bchWalletState)
// State
const [blurredMnemonic, setBlurredMnemonic] = useState(true)
const [blurredPrivateKey, setBlurredPrivateKey] = useState(true)
const [blurredNostrPrivKey, setBlurredNostrPrivKey] = useState(true)
const [nostrKeyPair] = useState(bchWalletState.nostrKeyPair)
// Encapsulate component state into an object that can be passed to child functions
const walletSummaryData = {
@@ -64,6 +67,15 @@ function WalletSummary (props) {
}
}
// Toggle the state of blurring for the private key
const toggleNostrPrivateKeyBlur = (inObj = {}) => {
try {
setBlurredNostrPrivKey(!blurredNostrPrivKey)
} catch (error) {
console.error('Error toggling private key blur: ', error)
}
}
return (
<>
<Container>
@@ -151,6 +163,32 @@ function WalletSummary (props) {
<CopyOnClick walletProp='hdPath' appData={appData} value={bchWalletState.hdPath} />
</Col>
</Row>
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
<Col xs={10} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Nostr Priv Key:</b> <span className={blurredNostrPrivKey ? 'blurred' : null}>{nostrKeyPair.nsec}</span>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={eyeIcon.privateKey}
size='lg'
onClick={() => toggleNostrPrivateKeyBlur(nostrKeyPair.nsec)}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick appData={appData} value={nostrKeyPair.nsec} />
</Col>
</Row>
<Row style={{ padding: '25px', backgroundColor: '#eee' }}>
<Col xs={10} sm={10} lg={8} style={{ padding: '10px' }}>
<b>Nostr Pub Key:</b> {nostrKeyPair.npub}
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }} />
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick appData={appData} value={nostrKeyPair.npub} />
</Col>
</Row>
</Container>
</Card.Body>
</Card>
@@ -0,0 +1,20 @@
/*
This component is a View that allows the user to handle configuration
settings for the app.
*/
// Global npm libraries
import React from 'react'
import ServerSelectView from './select-server-view'
function ConfigurationView (props) {
const { appData } = props
return (
<>
<ServerSelectView appData={appData} />
</>
)
}
export default ConfigurationView
@@ -9,7 +9,7 @@ import { Container, Row, Col, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
const ServerSelect = (props) => {
const { linkTo } = props
const { linkTo, appData } = props
// Use the navigate function to navigate to the servers view
const navigate = useNavigate()
@@ -31,6 +31,7 @@ const ServerSelect = (props) => {
<h5>
Having trouble loading? Try selecting a different back-end server.
</h5>
<p style={{ fontStyle: 'italic' }}>Current Server : {appData.serverUrl}</p>
<Button variant='warning' onClick={handleServerSelect}>
Select a different back end server
</Button>
@@ -0,0 +1,90 @@
/*
This component is a View that allows the user to select a back end server
from a list of servers.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Row, Col, Form, Card } from 'react-bootstrap'
function ServerSelectView (props) {
const { appData } = props
const [selectedServer, setSelectedServer] = useState(appData.serverUrl)
const servers = appData.servers
// Update server when dropdown selection changes
const handleServerChange = (event) => {
setSelectedServer(event.target.value)
}
const onSaveServer = (serverUrl) => {
console.log('server target: ', serverUrl)
appData.updateLocalStorage({ serverUrl })
window.location.href = '/'
}
return (
<>
<Card className='m-3'>
<Row className='mb-3 mt-3 mx-3'>
<Col className='text-end'>
<button
className='btn btn-primary'
style={{ minWidth: '100px' }}
onClick={() => onSaveServer(selectedServer)}
>
Save
</button>
</Col>
</Row>
<Card.Body>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>Configuration</h2>
<p>
This page allows you to change configuration settings for different
back end services. This page is for advanced users only.
</p>
</Col>
</Row>
<hr />
<Row className='justify-content-center'>
<Col xs={12} md={6}>
<p>
Select an alternative server below. The app will reload and use
the selected server.
</p>
<Form.Select
value={selectedServer}
onChange={handleServerChange}
className='mb-3'
>
{servers.map((server, i) => (
<option key={`server-${i}`} value={server.value}>
{server.value === appData.serverUrl ? `${server.label} (current)` : server.label}
</option>
))}
</Form.Select>
</Col>
</Row>
<Row className='justify-content-center mt-3'>
<Col xs={12} md={6} className='text-center'>
<button
className='btn btn-primary'
style={{ minWidth: '100px' }}
onClick={() => onSaveServer(selectedServer)}
>
Save
</button>
</Col>
</Row>
</Card.Body>
</Card>
</>
)
}
export default ServerSelectView
+28 -5
View File
@@ -15,9 +15,20 @@ import GetBalance from './balance'
import Wallet from './bch-wallet'
import Placeholder2 from './placeholder2'
import Placeholder3 from './placeholder3'
import ServerSelectView from './servers/select-server-view'
import SelectServerButton from './servers/select-server-button'
// import ServerSelectView from './servers/select-server-view'
// import SelectServerButton from './servers/select-server-button'
import NftsForSale from './nfts-for-sale'
import BchSend from './bch-send'
import SlpTokens from './slp-tokens'
import SweepWif from './sweep/index.js'
import SignMessage from './sign/index.js'
import ServerSelectView from './configuration/select-server-view'
import NostrPost from './nostr/nostr-post/index.js'
import Profile from './nostr/profile/index.js'
import Feeds from './nostr/feeds/index.js'
import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -25,15 +36,27 @@ function AppBody (props) {
return (
<>
<Routes>
<Route path='/' element={<GetBalance wallet={appData.wallet} />} />
<Route path='/' element={<NftsForSale appData={appData} />} />
<Route path='/balance' element={<GetBalance wallet={appData.wallet} />} />
<Route path='/bch' element={<BchSend appData={appData} />} />
<Route path='/wallet' element={<Wallet appData={appData} />} />
<Route path='/nfts-for-sale' element={<NftsForSale appData={appData} />} />
<Route path='/slp-tokens' element={<SlpTokens appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
{appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' />}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
</>
)
}
@@ -0,0 +1,220 @@
/*
This component renders as a button. When clicked, it initiates the
purchase of the token. This is the Signal part of the SWaP protocol.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
function BuyButton (props) {
const { token, appData, onSuccess } = props
console.log('props: ', props)
const [show, setShow] = useState(false) // show the modal
const [onFetch, setOnFetch] = useState(false) // show the spinner
const [error, setError] = useState(false) // show the error message
// const [eventId, setEventId] = useState(false) // show the event id
const [noteId, setNoteId] = useState(false) // show the note id
const [success, setSuccess] = useState(false) // show the success message
const [showConfirmation, setShowConfirmation] = useState(true) // show the confirmation view
const [progressMsg, setProgressMsg] = useState([]) // show the progress message
// Handler for when user clicks the "Buy" button.
const handleBuy = async () => {
try {
console.log('handleBuy()')
console.log('token: ', token)
console.log('appData: ', appData)
setShowConfirmation(false)
setOnFetch(true)
/* // dev-test success view
setShowConfirmation(false)
setSuccess(true)
setNoteId('note12986q83gre76vl9dldpnnhej7y67h76xzw0tx0hm4mq6uradr6lskch7v9')
setOnFetch(false)
return
*/
const progress = progressMsg
progress.push(<p key='progress-msg1'>Generating counter offer transaction...</p>)
setProgressMsg(progress)
const targetOffer = token.nostrEventId
console.log('targetOffer: ', targetOffer)
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
targetOffer
)
progress.push(<p key='progress-msg2'>Uploading counter offer to Nostr...</p>)
setProgressMsg(progress)
console.log('offerData: ', offerData)
console.log('partialHex: ', partialHex)
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
offerData,
partialHex
})
console.log(
`Counter Offer uploaded to Nostr with this event ID: ${eventId}`
)
console.log(`https://astral.psfoundation.info/${noteId}`)
setNoteId(noteId)
setSuccess(true)
setOnFetch(false)
} catch (error) {
setOnFetch(false)
setError(error.message)
}
}
// Handler for when user requests to close the modal.
const handleClose = () => {
console.log('handleClose()')
// Deny close if the purchase is in progress.
if (onFetch) {
return
}
// Call onSuccess() callback if the modal close after a success purchase.
if (success && onSuccess) {
onSuccess()
}
// Reset the state.
setError(false)
setSuccess(false)
setNoteId(false)
setShow(false)
setOnFetch(false)
setShowConfirmation(true)
}
// Replace with dummy button until token data is loaded.
if (!props.token.tokenData) {
return (
<>
<Button variant='success'>Buy</Button>
</>
)
}
return (
<>
<Button
variant='success'
disabled={props.disabled}
onClick={() => {
setShow(true)
}}
>
Buy
</Button>
<Modal show={show} onHide={handleClose} dialogClassName='buy-modal'>
<Modal.Header closeButton>
<Modal.Title>
{success
? (
<span style={{ color: 'green' }}>Successful Purchase</span>
)
: (
'Processing Purchase'
)}
</Modal.Title>
</Modal.Header>
<Modal.Body className='text-center'>
{/** If the purchase is not confirmed, display the token details. and confirm button. */}
{showConfirmation && (
<Container className='text-start'>
<Row>
<Col xs={4}>
<b>Ticker</b>:
</Col>
<Col xs={8}>{token.ticker}</Col>
</Row>
<Row>
<Col xs={4}>
<b>Name</b>:
</Col>
<Col xs={8}>{props.token.tokenData.genesisData.name}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}>
<b>Price</b>:
</Col>
<Col xs={8}>
<strong>{token.usdPrice}</strong>
</Col>
</Row>
</Container>
)}
{/** show purchase progress */}
<Container style={{ wordBreak: 'break-word' }}>
{onFetch && (
<>
<Spinner animation='border' variant='primary' />
{progressMsg}
</>
)}
{error && (
<span style={{ color: 'red', fontStyle: 'italic' }}>{error}</span>
)}
{success && (
<>
<p>
Note ID :
<a
href={`https://astral.psfoundation.info/${noteId}`}
target='_blank'
rel='noreferrer'
>
{' '}
{noteId}
</a>
</p>
<p>
What happens now:<br />
The Seller software must finalize the sale by accepting the Counter Offer transaction you just generated.
If their software is online, the token should appear in your wallet within a few minutes.
Before it is accepted, you can cancel the purchase by Sweeping your Counter Offer UXTO back to your wallet.
</p>
</>
)}
</Container>
</Modal.Body>
<Modal.Footer className='text-center'>
{showConfirmation && (
<Row style={{ width: '100%' }}>
<Col xs={12} className='text-center'>
<Button
variant='success'
style={{ minWidth: '100px' }}
onClick={handleBuy}
>
Buy
</Button>
</Col>
</Row>
)}
</Modal.Footer>
</Modal>
</>
)
}
export default BuyButton
@@ -0,0 +1,333 @@
/*
Shows NFTs for sale on the DEX.
workflow:
1. Load offers
2. Display token cards with current Offer data
3. Load tokens data
4. Add data to offers and update card with new data
5. Load tokens icons
6. Add icons to offers and update card with new icons
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import config from '../../../config'
import TokenCard from './token-card'
// Global variables and constants
const SERVER = config.dexServer
function NftsForSale (props) {
// Dependency injection through props
const appData = props.appData
const [offers, setOffers] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
// Handler for refresh button
const handleRefresh = () => {
console.log('handling refresh')
loadNftOffers()
}
// Function to process token data
const processTokenData = useCallback(async (offer) => {
try {
const { wallet, bchWalletState } = appData
const { bchjs } = wallet
// Calculate USD price
const rateInSats = parseInt(offer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * bchWalletState.bchUsdPrice * offer.numTokens
offer.usdPrice = `$${usdPrice.toFixed(3)}`
return offer
} catch (err) {
console.error('Error processing token:', err)
return offer
}
}, [appData])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
setOffersAreLoaded(false)
const result = await axios.get(`${SERVER}/offer/list/nft/${page}`)
const rawOffers = result.data
console.log('rawOffers: ', rawOffers)
// Process each offer
const processedOffers = []
for (let i = 0; i < rawOffers.length; i++) {
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
processedOffers.push(processedOffer)
}
setOffersAreLoaded(true)
return processedOffers
} catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
}
}, [processTokenData])
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
console.log('tokenData', tokenData)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
setDataAreLoaded(true)
}
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.tokenData.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
const cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
const cacheToken = cacheData[thisToken.tokenId]
// If cache data exists, add it to the token object
if (cacheToken) {
thisToken.tokenData = cacheToken.tokenData
thisToken.icon = cacheToken.tokenIcon
}
}
}, [appData])
// Check if token data exists in the cache and add it to the tokens object.
const updateNFTCachedData = useCallback(async (offers) => {
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// save token icon and token data in cache
const newTokenCacheData = {
tokenIcon: thisToken.icon,
tokenData: thisToken.tokenData
}
console.log('newTokenCacheData: ', newTokenCacheData)
appData.updateNFTCachedData(thisToken.tokenId, newTokenCacheData)
}
}, [appData])
// Main function to load NFT offers
const loadNftOffers = useCallback(async () => {
try {
setIsLoading(true)
// Get tokens in offers
const offers = await getNftOffers()
console.log('offers: ', offers)
// Review cached data to populate token data from cached data
await reviewNftCachedData(offers)
// set state to start displaying tokens cards.
setOffers(offers)
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons from mutable data
await lazyLoadMutableData(offers)
// Update cached data with the latest retrieved data
await updateNFTCachedData(offers)
setIsLoading(false)
} catch (err) {
setIsLoading(false)
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadMutableData, getNftOffers, reviewNftCachedData, updateNFTCachedData])
// Effect to load NFTs on component mount
useEffect(() => {
console.log('loading nfts for sale')
loadNftOffers()
}, [loadNftOffers])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function generates a Token Card for each token in the wallet.
function generateCards (offers) {
console.log('generateCards() offerData: ', offers)
const tokens = offers
const tokenCards = []
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
const thisTokenCard = (
<TokenCard
appData={appData}
token={thisToken}
handleRefresh={handleRefresh}
key={`${thisToken.tokenId + i}`}
/>
)
tokenCards.push(thisTokenCard)
}
return tokenCards
}
return (
<Container>
<Row>
<Col>
<h1>NFTs for Sale</h1>
</Col>
</Row>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
offersAreLoaded && !dataAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
{!isLoading && (
<Row>
<Col xs={6}>
<Button variant='success' onClick={handleRefresh}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
</Col>
</Row>
)}
{!offersAreLoaded && (
<Row className='d-block text-center'>
<Spinner animation='border' variant='primary' style={{ maegin: '0 auto' }} />
</Row>
)}
<Row>
{offersAreLoaded && generateCards(offers)}
</Row>
{/** Display a message if no tokens are found */}
{offersAreLoaded && offers.length === 0 && (
<Row className='text-center'>
<span> No Offers found. </span>
</Row>
)}
</Container>
)
}
export default NftsForSale
@@ -0,0 +1,123 @@
/*
This component renders as a button. When clicked, it opens a modal that
displays information about the token.
This is a functional component with as little state as possible.
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const handleClose = () => {
console.log('handleClose()')
setShow(false)
// props.instance.setState({ showModal: false })
}
const handleOpen = () => {
console.log('handleOpen()')
setShow(true)
}
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// Get token user data if it exists and verify if it contains media or markdown
useEffect(() => {
try {
const userDataStr = props.token.tokenData.userData
if (userDataStr) {
const userData = JSON.parse(userDataStr)
// If user data contains media or markdown, set the mutable data cid
if (userData?.media || userData?.markdown) {
setMutableDataCid(parseCid(props.token.tokenData.mutableData))
}
}
} catch (error) {
// Do nothing
}
}, [props.token, show])
// Replace with dummy button until token data is loaded.
if (!props.token.tokenData) {
return (
<>
<Button variant='info'>Info</Button>
</>
)
}
return (
<>
<Button variant='info' disabled={props.disabled} onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col xs={4}><b>Ticker</b>:</Col>
<Col xs={8}>{props.token.ticker}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.tokenData.genesisData.name}</Col>
</Row>
<Row>
<Col xs={4}><b>Token ID</b>:</Col>
<Col xs={8} style={{ wordBreak: 'break-all' }}>
<a href={`https://explorer.tokentiger.com/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
{props.token.tokenId}
</a>
</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.tokenData.genesisData.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
{mutableDataCid && (
<Row style={{ paddingTop: '10px' }}>
<Col xs={4}><b>User Data</b>:</Col>
<Col xs={8}>
<Button
href={`/user-data/${props.token.tokenId}#single-view`}
target='_blank'
rel='noopener noreferrer'
>
View User Data
</Button>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
@@ -0,0 +1,87 @@
/*
This Card component summarizes an SLP token.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import BuyButton from './buy-button'
function TokenCard (props) {
const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
console.log('TokenCard() appData: ', appData)
// Update icon state every token.icon and token.tokenData changes
useEffect(() => {
setIcon(token.icon)
setTokenData(token.tokenData)
}, [token.icon, token.tokenData])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{tokenData && tokenData.genesisData ? tokenData.genesisData.name : null}
</Col>
</Row>
<br />
<Row>
<Col>Price:</Col>
<Col>{token.usdPrice}</Col>
</Row>
<br />
<Row className='text-center'>
<Col>
<InfoButton token={token} disabled={!token.tokenData} />
</Col>
{!hideBuyBtn && (
<Col>
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
</Col>
)}
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default TokenCard
@@ -0,0 +1,244 @@
/**
* Content creators card
*/
import React, { useState, useEffect } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import FollowBtn from './follow-btn.js'
function ContentCard (props) {
const { creator, followList, refreshFollowList } = props
const [profile, setProfile] = useState(null)
const [imageError, setImageError] = useState(false)
useEffect(() => {
setProfile(creator?.profile)
}, [creator.profile])
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${creator.npub}#single-view`
window.open(profileUrl, '_blank')
}
const handleImageError = (type) => {
setImageError(true)
}
return (
<>
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-4'>
{/* Desktop Layout - Horizontal */}
<div className='d-flex justify-content-end mb-2' onClick={goToProfile}>
<small className='text-muted'>
{new Date(creator.createdAt).toLocaleString()}
</small>
</div>
<div className='d-none d-md-flex align-items-center gap-4 cursor-pointer'>
{/* Profile Picture */}
{(profile && profile.picture && !imageError) && (
<div className='flex-shrink-0 max-w-50' onClick={goToProfile}>
<img
src={profile.picture}
alt='Profile'
className='rounded-circle shadow'
style={{ objectFit: 'cover', width: '140px' }}
onError={() => handleImageError('picture')}
/>
</div>
)}
{/* Default profile Picture */}
{((profile && !profile.picture) || imageError) && (
<div className='flex-shrink-0' onClick={goToProfile}>
<Jdenticon size='140' value={creator.npub} />
</div>
)}
{/** */}
{!profile && (
<div
className='flex-shrink-0'
style={{ width: '140px', height: '140px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<Spinner size='140' />
</div>
)}
{/* Creator Info */}
<div className='flex-grow-1'>
<h5 className='mb-2 fw-bold cursor-pointer' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted medium mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-2'>
<div className='d-flex align-items-center gap-2 mb-1'>
<small className='text-muted fw-semibold'>Nostr Public Key:</small>
</div>
<div className='d-flex align-items-center gap-2'>
<div className='text-break small text-secondary'>
{creator.npub}
</div>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={creator.npub}
/>
</div>
</div>
{/* BCH Address */}
<div className='mb-3'>
<div className='d-flex align-items-center gap-2 mb-1'>
<small className='text-muted fw-semibold'>BCH Address:</small>
</div>
<div className='d-flex align-items-center gap-2'>
<div className='text-break small text-secondary'>
{creator.bchAddr}
</div>
<CopyOnClick
walletProp='bchAddress'
appData={props.appData}
value={creator.bchAddr}
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className='flex-shrink-0 d-flex flex-column gap-2'>
<FollowBtn
creator={creator}
appData={props.appData}
creatorProfile={profile}
followList={followList}
refreshFollowList={refreshFollowList}
/>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
>
<i className='bi bi-chat-dots me-1' />
Message
</button>
<div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span
className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2'
style={{ fontSize: '1rem', fontWeight: 600, cursor: 'auto' }}
>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div>
</div>
{/* Mobile Layout - Vertical */}
<div className='d-flex d-md-none flex-column align-items-center text-center'>
{/* Profile Picture */}
{(profile && profile.picture && !imageError) && (
<div className='flex-shrink-0 max-w-50 mb-2' onClick={goToProfile}>
<img
src={profile.picture}
alt='Profile'
className='rounded-circle shadow'
style={{ objectFit: 'cover', width: '100px' }}
onError={() => handleImageError('picture')}
/>
</div>
)}
{/* Default profile Picture */}
{((profile && !profile.picture) || imageError) && (
<div className='flex-shrink-0 mb-2' onClick={goToProfile}>
<Jdenticon size='100' value={creator.npub} />
</div>
)}
{/** */}
{!profile && (
<div
className='flex-shrink-0'
style={{ width: '100px', height: '100px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<Spinner size='100' />
</div>
)}
{/* Creator Info */}
<div className='w-100 mb-3'>
<h5 className='mb-2 fw-bold' onClick={goToProfile}>{profile?.name}</h5>
<p className='text-muted small mb-3'>{profile?.about}</p>
{/* Nostr Public Key */}
<div className='mb-3'>
<div className='mb-1'>
<small className='text-muted fw-semibold'>Nostr Public Key:</small>
</div>
<div className='d-flex align-items-center justify-content-center gap-2'>
<div className='text-break small text-secondary' style={{ maxWidth: '200px' }}>
{creator.npub}
</div>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={creator.npub}
/>
</div>
</div>
{/* BCH Address */}
<div className='mb-3'>
<div className='mb-1'>
<small className='text-muted fw-semibold'>BCH Address:</small>
</div>
<div className='d-flex align-items-center justify-content-center gap-2'>
<div className='text-break small text-secondary' style={{ maxWidth: '200px' }}>
{creator.bchAddr}
</div>
<CopyOnClick
walletProp='bchAddress'
appData={props.appData}
value={creator.bchAddr}
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className='d-flex gap-2'>
<FollowBtn
creator={creator}
appData={props.appData}
creatorProfile={profile}
followList={followList}
refreshFollowList={refreshFollowList}
/>
<button
className='btn btn-primary btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem' }}
>
<i className='bi bi-chat-dots me-1' />
Message
</button>
</div>
<div className='d-flex align-items-center justify-content-center gap-2 mt-2'>
<span className='badge rounded-pill bg-white border text-dark px-3 py-2 shadow-sm d-flex align-items-center gap-2' style={{ fontSize: '1rem', fontWeight: 600 }}>
<i className='bi bi-people-fill text-primary' style={{ fontSize: '1.2rem' }} />
<span style={{ fontWeight: 700 }}>{creator.followerCnt}</span>
<span className='text-muted' style={{ fontWeight: 400, fontSize: '0.9rem' }}>Followers</span>
</span>
</div>
</div>
</Card.Body>
</Card>
</>
)
}
export default ContentCard
@@ -0,0 +1,153 @@
/*
This component is used to display the list of content creators.
TODO:
- getFollowList() should aggregate all the followers from all the relays.
Currently each relay overwrites the last one.
- loadProfile() should use multiple relays. It should exit after the first
successful retrieval of data from a relay. If the relay fails to give data,
it should move on to the next relay.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Spinner, Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import axios from 'axios'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
// Global variables and constants
const SERVER = config.dexServer
function ContentCreators (props) {
const [creators, setCreators] = useState([])
const [loaded, setLoaded] = useState(false)
const [selectedFilter, setSelectedFilter] = useState('Most Followers')
const { appData } = props
const [followList, setFollowList] = useState([])
// Get the list of profiles followed by the user.
// It aggregates all the followers from all the relays.
const getFollowList = useCallback(async () => {
const { nostrKeyPair } = appData.bchWalletState
const pubKeyHex = nostrKeyPair.pubHex
const list = await appData.nostrQueries.getFollowList(pubKeyHex)
setFollowList(list)
}, [appData])
useEffect(() => {
const loadCreators = async () => {
try {
const creatorsRes = await axios.get(`${SERVER}/sm/list/all/0`)
const creators = creatorsRes.data
// console.log('creators', creators)
setCreators(creators)
setLoaded(true)
for (let i = 0; i < creators.length; i++) {
try {
const creator = creators[i]
const profile = await appData.nostrQueries.getProfile(creator.pubkey)
// the folowing lines should re-render the ContentCard
setCreators(prevCreators => {
const updatedCreators = [...prevCreators]
updatedCreators[i] = { ...updatedCreators[i], profile }
return updatedCreators
})
} catch (error) {
console.warn(error)
}
}
} catch (error) {
setLoaded(true)
}
}
if (!loaded) {
loadCreators()
getFollowList()
}
}, [loaded, followList, getFollowList, appData])
const filteredCreators = useCallback(() => {
if (!creators || creators.length === 0) {
return []
}
let filtered = [...creators]
if (selectedFilter === 'Most Followers') {
filtered = filtered.sort((a, b) => (b.followerCnt || 0) - (a.followerCnt || 0))
}
return filtered
}, [creators, selectedFilter])
return (
<Container className='mt-4 mb-5'>
<div className='mb-4'>
<h2 className='text-center mb-3'>Content Creators</h2>
<p className='text-center text-muted'>
Discover amazing creators tokenizing their content.
</p>
</div>
<div className='mb-4 d-flex justify-content-end'>
<Dropdown>
<Dropdown.Toggle variant='outline-secondary' className='d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faFilter} />
<span>{selectedFilter}</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setSelectedFilter('Most Followers')}>
Most Followers
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Tokens')}>
Most Tokens
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Most Likes')}>
Most Likes
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
<div className='mt-3'>Loading content creators...</div>
</div>
)}
{loaded && (
<div>
{filteredCreators().map((creator, i) => (
<ContentCard
key={`creator-key${i}`}
creator={creator}
appData={props.appData}
followList={followList}
refreshFollowList={getFollowList}
/>
))}
</div>
)}
{loaded && filteredCreators().length === 0 && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-people-fill fs-1 mb-3 d-block opacity-50' />
<div>No content creators found</div>
</div>
)}
</Container>
)
}
export default ContentCreators
@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import { Spinner } from 'react-bootstrap'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils'
function FollowBtn (props) {
const [onFetch, setOnFetch] = useState(false)
const [isFollowing, setIsFollowing] = useState(false)
const { creator, appData, creatorProfile, followList, refreshFollowList } = props
useEffect(() => {
const isFollowing = followList.find(item => item[1] === creator.pubkey)
setIsFollowing(isFollowing)
}, [followList, creator.pubkey])
// Add creator to the follow list
const handleFollow = async () => {
try {
setOnFetch(true)
// Get current follow list
const currentList = followList
console.log('currentList', currentList)
// find if creator is in the list
const existing = currentList.find(item => item[1] === creator.pubkey)
if (!existing) {
const creatorName = creatorProfile.name || ''
// add creator to the list
currentList.push(['p', creator.pubkey, 'wss://nostr-relay.psfoundation.info', creatorName])
await submitFollowList(currentList)
await refreshFollowList()
} else {
console.log('Already Followed')
}
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
// Add creator to the follow list
const handleUnfollow = async () => {
try {
setOnFetch(true)
// Get current follow list
const currentList = followList
console.log('currentList', currentList)
// find if creator is in the list
const existing = currentList.find(item => item[1] === creator.pubkey)
if (existing) {
// remove creator from the list
currentList.splice(currentList.indexOf(existing), 1)
await submitFollowList(currentList)
await refreshFollowList()
}
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
const submitFollowList = async (newList) => {
try {
const { nostrKeyPair } = appData.bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
// Generate a post.
const eventTemplate = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: newList,
content: ''
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(psf)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
setTimeout(() => {
setOnFetch(false)
}, 1000)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
return (
<>
{onFetch && (
<div className='d-flex justify-content-center'>
<Spinner animation='border' />
</div>
)}
{!onFetch && !isFollowing && (
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
onClick={handleFollow}
>
<i className='bi bi-person-plus me-1' />
Follow
</button>
)}
{!onFetch && isFollowing && (
<button
className='btn btn-outline-danger btn-sm rounded-pill px-3'
style={{ fontSize: '0.875rem', minWidth: '100px' }}
onClick={handleUnfollow}
>
<i className='bi bi-person-plus me-1' />
Unfollow
</button>
)}
</>
)
}
export default FollowBtn
@@ -0,0 +1,15 @@
import React from 'react'
import { Container } from 'react-bootstrap'
import ContentCreatorsList from './content-creators-list.js'
function ContentCreators (props) {
return (
<>
<Container>
<ContentCreatorsList {...props} />
</Container>
</>
)
}
export default ContentCreators
@@ -0,0 +1,260 @@
/**
* Component for displaying nostr posts
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart } from '@fortawesome/free-regular-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import config from '../../../../config/index.js'
// Local libraries
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import NostrFormat from '../nostr-format'
function FeedCard (props) {
const { post, appData, profiles } = props
const { nostrKeyPair } = appData.bchWalletState
const [profile, setProfile] = useState(profiles[post.pubkey])
const [npub, setNpub] = useState('')
const [isLiked, setIsLiked] = useState(false)
const [likesCount, setLikesCount] = useState(null)
const [likesFetched, setLikesFetched] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
// function to fetch a post likes reaction
const handleLikes = useCallback(async (post, userPubKey) => {
try {
// Get all post likes events
const likesArr = await appData.nostrQueries.getPostLikes(post.id)
const likesCount = likesArr.length
// Verify if the users pubkey is in the likes array
const userLikedPost = likesArr.find(val => { return val.pubkey === userPubKey })
setLikesCount(likesCount)
setIsLiked(userLikedPost)
setLikesFetched(true)
} catch (error) {
console.warn(error)
}
}, [appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
// Get npub from pubkey
useEffect(() => {
const npub = nip19.npubEncode(post.pubkey)
setNpub(npub)
// Handle post likes
if (!likesFetched && nostrKeyPair.pubHex) {
handleLikes(post, nostrKeyPair.pubHex)
}
}, [post, nostrKeyPair, handleLikes, likesFetched])
// Verify if pubkey profile exists into the profiles state
useEffect(() => {
if (profiles[post.pubkey]) {
setProfile(profiles[post.pubkey])
}
}, [profiles, post])
// get short npub
const getShortNpub = useCallback((npub) => {
return npub.slice(0, 8) + '...' + npub.slice(-5)
}, [])
const submitLike = async (e) => {
// Post on nostr network
e.preventDefault()
try {
const { nostrKeyPair } = appData.bchWalletState
setLikesFetched(false)
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Define if like or dislike
let content = '+'
if (isLiked) { content = '-' }
// Generate a post.
const eventTemplate = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', post.id],
['p', nostrKeyPair.pubHex]
],
content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
config.nostrRelays.map(async (relayUrl) => {
try {
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
await handleLikes(post, nostrKeyPair.pubHex)
setLikesFetched(true)
} catch (error) {
console.warn(error)
setLikesFetched(true)
}
}
const goToProfile = () => {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
return (
<Card className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
{profile?.picture && !profilePictureError
? (
<img
src={profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover', cursor: 'pointer' }}
onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad}
onClick={goToProfile}
/>
)
: (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' style={{ cursor: 'pointer' }} onClick={goToProfile} />
)}
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>
{profile && profile.name && <span style={{ cursor: 'pointer' }} onClick={goToProfile}>{profile.name}</span>}
{!profile?.name && (
<span style={{ cursor: 'pointer' }} onClick={goToProfile}>
{post.pubkey.slice(0, 8) + '...'}
{!profile?.loaded && <Spinner animation='border' size='sm' className='ms-2' />}
</span>
)}
</div>
{npub && (
<small className='text-muted'>
<span
title='Copy to clipboard'
style={{
cursor: 'pointer',
transition: 'transform 0.1s ease',
display: 'inline-block',
marginRight: '5px'
}}
onClick={goToProfile}
>
{getShortNpub(npub)}
</span>
<CopyOnClick
walletProp='npub'
appData={props.appData}
value={npub}
/>
</small>
)}
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
<NostrFormat content={post.content} />
</div>
</div>
<div className='d-flex justify-content-end'>
{likesFetched && (
<button
onClick={submitLike}
className='btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2'
style={{
border: 'none',
background: 'transparent',
transition: 'all 0.2s ease',
color: isLiked ? '#e31b23' : '#536471',
fontSize: '14px',
fontWeight: '400'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.05)'
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)'
}}
>
<FontAwesomeIcon
icon={isLiked ? faHeartSolid : faHeart}
style={{
fontSize: '16px',
color: isLiked ? '#e31b23' : '#536471',
transition: 'all 0.2s ease'
}}
/>
<span
style={{
fontSize: '14px',
fontWeight: '400',
color: isLiked ? '#e31b23' : '#536471',
transition: 'all 0.2s ease'
}}
>
{likesCount || 0}
</span>
</button>
)}
{!likesFetched && (
<Spinner animation='border' size='sm' className='ms-2' />
)}
</div>
</Card.Body>
</Card>
)
}
export default FeedCard
@@ -0,0 +1,30 @@
/**
* Component for displaying global nostr posts
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
import FeedCard from './feed-card'
function Feed (props) {
const { posts, profiles } = props
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{posts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{!posts.length && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
</div>
</Container>
)
}
export default Feed
@@ -0,0 +1,82 @@
/**
* Component for displaying following nostr posts
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap'
import { RelayPool } from 'nostr'
// Local libraries
import config from '../../../../config'
import FeedCard from './feed-card'
function Following (props) {
const { appData, posts, profiles } = props
const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const getFollowingPosts = async () => {
const followingList = await new Promise((resolve, reject) => {
let list = []
const { nostrKeyPair } = appData.bchWalletState
const pool = RelayPool(config.nostrRelays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
relay.close()
/** This ensures to return an empty array if no records are found!
* Applies for new users that don't have a follow list
*/
resolve(list)
})
pool.on('event', (relay, subId, ev) => {
list = ev.tags
resolve(list)
})
})
// Filter posts by following list
let filteredPosts = []
for (let i = 0; i < followingList.length; i++) {
const following = followingList[i]
const filtered = posts.filter(post => {
return following.includes(post.pubkey)
})
filteredPosts = [...filteredPosts, ...filtered]
}
// Sort filtered posts by created_at
filteredPosts.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
setFollowingPosts(filteredPosts)
setLoaded(true)
}
getFollowingPosts()
}, [appData, posts])
return (
<Container className='mt-4 mb-5' style={{ marginBottom: '50px' }}>
<div>
{loaded && followingPosts.map((post, index) => (
<FeedCard key={index} post={post} appData={props.appData} profiles={profiles} />
))}
{loaded && !followingPosts.length && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</Container>
)
}
export default Following
@@ -0,0 +1,108 @@
/*
Component for reading the nostr feeds.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Nav, Tab, Spinner } from 'react-bootstrap'
// Local libraries
import Feed from './feed'
import Following from './following'
function Feeds (props) {
const { appData } = props
const [activeTab, setActiveTab] = useState(appData.lastFeedTab)
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profiles, setProfiles] = useState({})
// Load data on component mount.
useEffect(() => {
const loadData = async () => {
// Get feeds
const feeds = await appData.nostrQueries.getGlobalFeeds()
setPosts(feeds)
setLoaded(true)
const loadedProfiles = [] // fetched profiles ( this will be used for prevent load the same profile multiple times.)
// Map feeds and get feed owner profile.
for (let i = 0; i < feeds.length; i++) {
const pubKey = feeds[i].pubkey
const exist = loadedProfiles.find((val) => { return val === pubKey })
if (exist) { continue }
// Fech profile.
const profile = await appData.nostrQueries.getProfile(pubKey)
loadedProfiles.push(pubKey) // mark as loaded
// Update profile state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
return newProfiles
})
}
}
if (!loaded) {
loadData()
}
}, [loaded, appData])
const onChangeTab = (tab) => {
setActiveTab(tab)
appData.setLastFeedTab(tab)
appData.updateLocalStorage({ lastFeedTab: tab })
}
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0 30px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Feeds
</h2>
{loaded && (
<Tab.Container activeKey={activeTab} onSelect={onChangeTab}>
<Nav variant='tabs' className='mb-4'>
<Nav.Item>
<Nav.Link eventKey='feed' className={activeTab === 'feed' ? 'active' : ''}>
Feeds
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey='following' className={activeTab === 'following' ? 'active' : ''}>
Following
</Nav.Link>
</Nav.Item>
</Nav>
<Tab.Content>
<Tab.Pane eventKey='feed'>
<Feed appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
<Tab.Pane eventKey='following'>
<Following appData={appData} posts={posts} profiles={profiles} />
</Tab.Pane>
</Tab.Content>
</Tab.Container>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</Container>
</>
)
}
export default Feeds
@@ -0,0 +1,33 @@
/**
* Parse nostr content to html , support markdown , links , line breaks
*/
import React from 'react'
import { marked } from 'marked' // https://www.npmjs.com/package/marked/v/0.3.9
const NostrFormat = ({ content }) => {
const formatContent = (content) => {
if (!content) return ''
// create marked renderer
const renderer = new marked.Renderer()
// customize link renderer to open in new tab
renderer.link = function ({ href, text }) {
return `<a href="${href}" target="_blank" >${text}</a>`
}
// set marked options
marked.setOptions({
renderer,
breaks: true
})
// parse content to html
const html = marked.parse(content)
return html
}
return (
<div style={{ overflow: 'auto' }} dangerouslySetInnerHTML={{ __html: formatContent(content) }} />
)
}
export default NostrFormat
@@ -0,0 +1,33 @@
/*
Component for posting nostr information.
*/
// Global npm libraries
import React from 'react'
import { Container } from 'react-bootstrap'
// Local libraries
import ProfilePost from './profile-post'
import PublicPost from './public-post'
function NostrPost (props) {
return (
<>
<Container>
<h2 style={{
textAlign: 'center',
margin: '20px 0',
padding: '10px',
borderBottom: '2px solid #ccc'
}}
>
Nostr Post
</h2>
<ProfilePost {...props} />
<PublicPost {...props} />
</Container>
</>
)
}
export default NostrPost
@@ -0,0 +1,253 @@
/*
Component for posting nostr information on kind 0.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries
import config from '../../../../config'
function ProfilePost (props) {
const { appData } = props
const { bchWalletState } = appData
const [accordionKey, setAccordionKey] = useState(null)
const [onFetch, setOnFetch] = useState(false)
const [formLoaded, setFormLoaded] = useState(false)
const [formData, setFormData] = useState({
name: '',
about: '',
picture: '',
display_name: '',
website: '',
banner: '',
bot: false
})
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
// Get Last post from
const getLastProfilePost = async () => {
const { nostrKeyPair } = appData.bchWalletState
const lastProfile = await appData.nostrQueries.getProfile(nostrKeyPair.pubHex)
if (lastProfile) {
setFormData(lastProfile)
}
setFormLoaded(true)
}
if (!formLoaded) {
getLastProfilePost()
}
}, [appData, formLoaded])
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData({
...formData,
[name]: value
})
setSuccessMsg('')
setErrorMsg('')
}
// Post on nostr network
const handleSubmit = async (e) => {
e.preventDefault()
try {
setErrorMsg('')
setSuccessMsg('')
setOnFetch(true)
const { nostrKeyPair } = bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
// const psf = 'wss://nostr-relay.psfoundation.info'
const formDataString = JSON.stringify(formData)
// Generate a post.
const eventTemplate = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: formDataString
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
setSuccessMsg('Post successfully published!')
setOnFetch(false)
} catch (error) {
console.warn(error)
setErrorMsg(error.message || 'An error occurred while posting')
setOnFetch(false)
}
}
const handleAccordionChange = (key) => {
setAccordionKey(key)
}
return (
<>
<Container className='mt-4'>
<Accordion activeKey={accordionKey} onSelect={handleAccordionChange}>
<Accordion.Item eventKey='0'>
<Accordion.Header>Profile Post</Accordion.Header>
<Accordion.Body>
{errorMsg && (
<div className='alert alert-danger' role='alert'>
{errorMsg}
</div>
)}
{successMsg && (
<div className='alert alert-success' role='alert'>
{successMsg}
</div>
)}
{formLoaded && (
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your name'
name='name'
value={formData.name}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>About</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
onChange={handleInputChange}
required
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Profile Picture URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your profile picture'
name='picture'
value={formData.picture}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Display Name</Form.Label>
<Form.Control
type='text'
placeholder='Enter your display name'
name='display_name'
value={formData.display_name}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Website</Form.Label>
<Form.Control
type='url'
placeholder='Enter your website URL'
name='website'
value={formData.website}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Banner URL</Form.Label>
<Form.Control
type='url'
placeholder='Enter URL to your banner picture (1024x768 pixels)'
name='banner'
value={formData.banner}
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group className='mb-3'>
<Form.Check
type='checkbox'
label='This account is for a bot'
name='bot'
checked={formData.bot}
onChange={(e) => {
setFormData({
...formData,
bot: e.target.checked
})
setSuccessMsg('')
setErrorMsg('')
}}
/>
</Form.Group>
<div className='d-flex justify-content-center'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
</div>
</Form>
)}
{!formLoaded && (
<div className='text-center'>
<Spinner animation='border' variant='primary' />
</div>
)}
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Container>
</>
)
}
export default ProfilePost
@@ -0,0 +1,153 @@
/*
Component for posting nostr information on kind 1.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
// Local libraries
import config from '../../../../config'
function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
const [formData, setFormData] = useState({
content: ''
})
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData({
...formData,
[name]: value
})
setSuccessMsg('')
setErrorMsg('')
}
// Post on nostr network
const handleSubmit = async (e) => {
e.preventDefault()
try {
setErrorMsg('')
setSuccessMsg('')
setOnFetch(true)
const { nostrKeyPair } = bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// BCH address of the user.
const bchAddr = bchWalletState.address
// Generate a post.
const eventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [['t', 'slpdex-socialmedia'], ['u', bchAddr]],
content: formData.content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the post to each relay.
config.nostrRelays.map(async (relayUrl) => {
try {
// Connect to a relay.
const relay = await Relay.connect(relayUrl)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
} catch (err) {
console.warn(`Skipping publishing to ${relayUrl} due to error: ${err}`)
}
})
resetForm()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
} catch (error) {
console.warn(error)
setErrorMsg(error.message || 'An error occurred while posting')
setOnFetch(false)
}
}
const handleAccordionChange = (key) => {
setAccordionKey(key)
}
const resetForm = () => {
setFormData({
content: ''
})
}
return (
<>
<Container className='mt-4'>
<Accordion activeKey={accordionKey} onSelect={handleAccordionChange}>
<Accordion.Item eventKey='0'>
<Accordion.Header>Public Post</Accordion.Header>
<Accordion.Body>
{errorMsg && (
<div className='alert alert-danger' role='alert'>
{errorMsg}
</div>
)}
{successMsg && (
<div className='alert alert-success' role='alert'>
{successMsg}
</div>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className='mb-3'>
<Form.Label>Message</Form.Label>
<Form.Control
as='textarea'
rows={7}
placeholder="What's happening?"
name='content'
value={formData.content}
onChange={handleInputChange}
required
/>
</Form.Group>
<div className='d-flex justify-content-center'>
{!onFetch && (
<Button variant='primary' type='submit' disabled={onFetch}>
Post
</Button>
)}
{onFetch && <Spinner animation='border' variant='primary' />}
</div>
</Form>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Container>
</>
)
}
export default PublicPost
@@ -0,0 +1,29 @@
/*
Component for read nostr information.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container } from 'react-bootstrap'
import ProfileRead from './profile-read.js'
import PublicRead from './public-read.js'
import { useParams } from 'react-router-dom'
import SlpTokensDisplay from './slp-tokens-display'
import NFTForSale from './nft-for-sale.js'
function Profile (props) {
const [profile, setProfile] = useState(false)
const { npub } = useParams()
console.log('npub', npub)
return (
<>
<Container>
<ProfileRead {...props} onProfileRead={setProfile} npub={npub} />
<PublicRead {...props} profile={profile} npub={npub} />
<SlpTokensDisplay {...props} npub={npub} />
<NFTForSale {...props} npub={npub} />
</Container>
</>
)
}
export default Profile
@@ -0,0 +1,327 @@
/**
* Component for displaying SLP tokens in a grid layout
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import config from '../../../../config'
import axios from 'axios'
import TokenCard from '../../nfts-for-sale/token-card'
function NFTForSale (props) {
const { appData, npub } = props
const [offers, setOffers] = useState([])
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [startAsync, setStartAsync] = useState(false)
const getAddressByNpub = useCallback(async () => {
const url = `${config.dexServer}/sm/npub/${npub}`
const response = await axios.get(url)
const addr = response.data.bchAddr
return addr
}, [npub])
// Handler for refresh button
const handleRefresh = () => {
console.log('handle refresh')
loadNftOffers()
}
// Function to process token data
const processTokenData = useCallback(async (offer) => {
try {
const { wallet, bchWalletState } = appData
const { bchjs } = wallet
// Calculate USD price
const rateInSats = parseInt(offer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * bchWalletState.bchUsdPrice * offer.numTokens
offer.usdPrice = `$${usdPrice.toFixed(3)}`
return offer
} catch (err) {
console.error('Error processing token:', err)
return offer
}
}, [appData])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
setOffersAreLoaded(false)
const addr = await getAddressByNpub(npub)
const url = `${config.dexServer}/offer/list/addr/${addr}`
const result = await axios.get(url)
const rawOffers = result.data
console.log('rawOffers: ', rawOffers)
// Process each offer
const processedOffers = []
for (let i = 0; i < rawOffers.length; i++) {
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
processedOffers.push(processedOffer)
}
setOffersAreLoaded(true)
return processedOffers
} catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
}
}, [processTokenData, getAddressByNpub, npub])
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
console.log('tokenData', tokenData)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
setDataAreLoaded(true)
}
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const { iconUrl, userData } = await fetchTokenMutableData(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
thisToken.tokenData.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
// Check if token data exists in the cache and add it to the tokens object.
const reviewNftCachedData = useCallback(async (offers) => {
const cacheData = appData.nftForSaleCacheData
console.log(`Token data from cache: ${JSON.stringify(cacheData, null, 2)}`)
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
const cacheToken = cacheData[thisToken.tokenId]
// If cache data exists, add it to the token object
if (cacheToken) {
thisToken.tokenData = cacheToken.tokenData
thisToken.icon = cacheToken.tokenIcon
}
}
}, [appData])
// Check if token data exists in the cache and add it to the tokens object.
const updateNFTCachedData = useCallback(async (offers) => {
// Map all offers
for (let i = 0; i < offers.length; i++) {
const thisToken = offers[i]
// save token icon and token data in cache
const newTokenCacheData = {
tokenIcon: thisToken.icon,
tokenData: thisToken.tokenData
}
console.log('newTokenCacheData: ', newTokenCacheData)
appData.updateNFTCachedData(thisToken.tokenId, newTokenCacheData)
}
}, [appData])
// Main function to load NFT offers
const loadNftOffers = useCallback(async () => {
try {
// Get tokens in offers
const offers = await getNftOffers()
console.log('offers: ', offers)
// Review cached data to populate token data from cached data
await reviewNftCachedData(offers)
// set state to start displaying tokens cards.
setOffers(offers)
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons from mutable data
await lazyLoadMutableData(offers)
// Update cached data with the latest retrieved data
await updateNFTCachedData(offers)
} catch (err) {
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadMutableData, getNftOffers, reviewNftCachedData, updateNFTCachedData])
// Effect to load NFTs on component mount
useEffect(() => {
// Prevent to load data twice
if (!startAsync) {
console.log('loading nfts for sale')
loadNftOffers()
setStartAsync(true)
}
}, [loadNftOffers, startAsync])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function generates a Token Card for each token in the wallet.
function generateCards (offers) {
console.log('generateCards() offerData: ', offers)
const tokens = offers
const tokenCards = []
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
const thisTokenCard = (
<TokenCard
appData={appData}
token={thisToken}
handleRefresh={handleRefresh}
key={`${thisToken.tokenId + i}`}
hideBuyBtn
hideSendBtn
/>
)
tokenCards.push(thisTokenCard)
}
return tokenCards
}
return (
<Container>
{!offersAreLoaded && (
<div className='d-flex justify-content-center align-items-center h-100 mb-4'>
<Spinner />
</div>
)}
{offersAreLoaded && offers.length !== 0 && (
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>Tokens For Sale</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
offersAreLoaded && !dataAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
{!offersAreLoaded && (
<Row className='d-block text-center'>
<Spinner animation='border' variant='primary' style={{ maegin: '0 auto' }} />
</Row>
)}
<Row>
{offersAreLoaded && generateCards(offers)}
</Row>
</div>
</div>
)}
</Container>
)
}
export default NFTForSale
@@ -0,0 +1,159 @@
/**
* Component to read nostr information kind 0 (normal posts)
*
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Button } from 'react-bootstrap'
import CopyOnClick from '../../bch-wallet/copy-on-click.js'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faGlobe } from '@fortawesome/free-solid-svg-icons'
import * as nip19 from 'nostr-tools/nip19'
// Local libraries
import NostrFormat from '../nostr-format'
function ProfileRead (props) {
const { npub, appData } = props
const { onProfileRead } = props
const [profile, setProfile] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
useEffect(() => {
const start = async () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
const profile = await appData.nostrQueries.getProfile(pubHex)
if (profile) {
onProfileRead(profile)
setProfile(profile)
}
setLoaded(true)
}
if (!loaded && npub) {
start()
}
}, [loaded, onProfileRead, npub, appData])
const handleImageError = (type) => {
setImageError(prev => ({ ...prev, [type]: true }))
}
const handleImageLoad = (type) => {
setImageError(prev => ({ ...prev, [type]: false }))
}
return (
<Container>
{/* Banner Section */}
{profile.banner && !imageError.banner && (
<div className='position-relative'>
<img
src={profile.banner}
alt='Profile banner'
className='w-100 rounded-4 shadow-sm'
style={{ height: '200px', objectFit: 'cover' }}
onError={() => handleImageError('banner')}
onLoad={() => handleImageLoad('banner')}
/>
</div>
)}
<div className='d-flex flex-column flex-md-row align-items-center gap-4 mb-5 p-3 p-md-5 bg-light rounded-4 shadow-sm'>
{/* Profile Picture */}
<div style={{ width: '120px', height: '120px' }} className='mb-3 mb-md-0'>
{profile.picture && !imageError.picture
? (
<img
src={profile.picture}
alt='Profile'
className='rounded-circle shadow w-100 h-100'
style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')}
onLoad={() => handleImageLoad('picture')}
/>
)
: (
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
<FontAwesomeIcon icon={faUser} size='3x' color='#7c7c7d' />
</div>
)}
</div>
{/* Profile Information */}
<div className='flex-grow-1 text-center text-md-start mb-3 mb-md-0 d-flex flex-column align-items-center align-items-md-start'>
<h3 className='mb-2 fw-bold'>{profile.name || 'username'}</h3>
<div className='text-muted small mb-3 d-flex align-items-center flex-column flex-md-row'>
<span className='text-truncate me-2 mb-2 mb-md-0'>
<span className='d-md-none'>
{`${npub?.slice(0, 8)}...${npub?.slice(-5)}`}
</span>
<span className='d-none d-md-inline'>
{npub}
</span>
</span>
<CopyOnClick walletProp='npub' appData={props.appData} value={npub} />
</div>
{/* About Section */}
{profile.about && (
<div className='fs-6 text-secondary'>
<NostrFormat content={profile.about} />
</div>
)}
{/* Website Link */}
{profile.website && (
<div className='mb-3 d-flex align-items-center gap-2'>
<FontAwesomeIcon icon={faGlobe} className='text-muted' size='sm' />
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target='_blank'
rel='noopener noreferrer'
className='text-decoration-none text-muted small'
style={{ wordBreak: 'break-all' }}
>
{profile.website}
</a>
</div>
)}
</div>
{/* Action Buttons */}
<div className='d-flex gap-2 align-items-center flex-wrap justify-content-center'>
<Button
variant='outline-danger'
className='px-3 px-md-4 py-2 rounded-pill fw-semibold'
style={{ minWidth: '120px' }}
>
<i className='bi bi-person-plus me-2' />
Follow
</Button>
<Button
variant='primary'
className='px-3 px-md-4 py-2 rounded-pill fw-semibold'
style={{ minWidth: '120px' }}
>
<i className='bi bi-chat-dots me-2' />
Message
</Button>
</div>
</div>
</Container>
)
}
export default ProfileRead
@@ -0,0 +1,119 @@
/**
* Component for read nostr information kind 1
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import NostrFormat from '../nostr-format'
function PublicRead (props) {
const { npub, appData } = props
const { bchWalletState } = appData
const [posts, setPosts] = useState([])
const [loaded, setLoaded] = useState(false)
const [profilePictureError, setProfilePictureError] = useState(false)
useEffect(() => {
// Get Last post from a author
const start = async () => {
const pubKeyHex = appData.nostrQueries.npubToHex(npub)
const feeds = await appData.nostrQueries.getUserFeeds(pubKeyHex)
setPosts(feeds)
setLoaded(true)
}
if (!loaded && npub) {
start()
}
}, [bchWalletState, loaded, npub, appData])
const handleProfilePictureError = () => {
setProfilePictureError(true)
}
const handleProfilePictureLoad = () => {
setProfilePictureError(false)
}
return (
<Container className='mt-4 mb-5'>
{/* Nostr Feed Section */}
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>Nostr Feed</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
{posts.map((post, index) => (
<Card key={index} className='mb-4 bg-light rounded-4 shadow-sm border-0'>
<Card.Body className='p-3'>
<div className='d-flex align-items-center gap-3 mb-3'>
<div
className='rounded-circle bg-gradient shadow d-flex align-items-center justify-content-center'
style={{
width: '48px',
height: '48px',
background: 'linear-gradient(45deg, #6c757d, #495057)'
}}
>
{props.profile?.picture && !profilePictureError
? (
<img
src={props.profile.picture}
alt='Profile'
className='rounded-circle w-100 h-100'
style={{ objectFit: 'cover' }}
onError={handleProfilePictureError}
onLoad={handleProfilePictureLoad}
/>
)
: (
<FontAwesomeIcon icon={faUser} size='1x' color='#7c7c7d' />
)}
</div>
<div className='flex-grow-1'>
<div className='fw-bold mb-1'>{props.profile?.name}</div>
<small className='text-muted'>
{`${npub.slice(0, 8)}...${npub.slice(-5)}`}
</small>
</div>
<small className='text-muted'>
{new Date(post.created_at * 1000).toLocaleString()}
</small>
</div>
<div className='mb-4'>
<div className='fs-5 ms-5 text-dark' style={{ lineHeight: '1.3' }}>
<NostrFormat content={post.content} />
</div>
</div>
</Card.Body>
</Card>
))}
{!posts.length && loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<i className='bi bi-inbox-fill fs-1 mb-3 d-block opacity-50' />
<div>No posts found</div>
</div>
)}
{!loaded && (
<div className='text-center text-muted py-5 bg-light rounded-4 shadow-sm'>
<Spinner animation='border' />
</div>
)}
</div>
</div>
</Container>
)
}
export default PublicRead
@@ -0,0 +1,169 @@
/**
* Component for displaying SLP tokens in a grid layout
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import config from '../../../../config'
import axios from 'axios'
import TokenCard from '../../slp-tokens/token-card'
function SlpTokensDisplay (props) {
const { appData } = props
const { bchWalletState, wallet } = props.appData
const { npub } = props
const [loaded, setLoaded] = useState(false)
const [tokens, setTokens] = useState([])
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// Fetch mutable data if it exist and get the token icon url
const fetchTokenIcon = useCallback(async (token) => {
try {
// Get the token data
const tokenData = await appData.wallet.getTokenData(token.tokenId)
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
if (!json) return false
const iconUrl = json.tokenIcon
// Return icon url
return iconUrl
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokenList) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokenList.length; i++) {
try {
const thisToken = tokenList[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenIcon(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
// replace the token in the tokens array
const updatedTokens = [...tokenList]
updatedTokens[i] = thisToken
setTokens(updatedTokens)
} catch (error) {
continue
}
}
setIconsAreLoaded(true)
} catch (error) {
console.warn(error)
setIconsAreLoaded(true)
}
}, [fetchTokenIcon, setTokens])
useEffect(() => {
// setTokens(bchWalletState.slpTokens || [])
const getTokens = async () => {
try {
const { nostrKeyPair } = bchWalletState
if (nostrKeyPair?.npub === npub) {
setTokens(bchWalletState.slpTokens)
setLoaded(true)
return
}
setLoaded(false)
const url = `${config.dexServer}/sm/npub/${npub}`
const response = await axios.get(url)
console.log('response', response.data)
const addr = response.data.bchAddr
const tokenList = await wallet.listTokens(addr)
setTokens(tokenList)
// setTokens(response.data)
setLoaded(true)
lazyLoadTokenIcons(tokenList)
} catch (error) {
console.error('Error fetching tokens:', error)
setLoaded(true)
}
}
if (!loaded) {
getTokens()
}
}, [bchWalletState, npub, loaded, lazyLoadTokenIcons, wallet])
return (
<Container>
{!loaded && (
<div className='d-flex justify-content-center align-items-center h-100 mb-4'>
<Spinner />
</div>
)}
{loaded && tokens.length !== 0 &&
<div className='bg-white rounded-1 shadow-sm border mb-4'>
<div className='p-3 border-bottom bg-light rounded-top-4'>
<h5 className='mb-0 fw-bold text-dark'>SLP Tokens</h5>
</div>
<div
className='p-3'
style={{
height: '800px',
overflowY: 'auto',
maxHeight: '800px'
}}
>
<Row>
{!iconsAreLoaded &&
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
</Col>}
{tokens.map(thisToken => (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
refreshTokens={() => { }}
hideSendBtn
/>
))}
</Row>
</div>
</div>}
</Container>
)
}
export default SlpTokensDisplay
+231
View File
@@ -0,0 +1,231 @@
/*
This React components downloads the active Offers from the REST API and
displays them in a data table.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Table, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import { DatatableWrapper, TableBody, TableHeader } from 'react-bs-datatable'
// Local libraries
import config from '../../../config'
import WaitingModal from '../../waiting-modal'
// Global variables and constants
const SERVER = `${config.dexServer}/`
const TABLE_HEADERS = [
{
prop: 'ticker',
title: 'Ticker',
isFilterable: true
},
{
prop: 'tokenId',
title: 'Token ID'
},
{
prop: 'buyOrSell',
title: 'Type'
},
{
prop: 'smallNostrEventId',
title: 'Event ID'
},
{
prop: 'numTokens',
title: 'Quantity'
},
{
prop: 'usdPrice',
title: 'Price (USD)'
},
{
prop: 'button',
title: 'Action'
}
]
function Offers (props) {
const [appData] = useState(props.appData)
const [offers, setOffers] = useState([])
// Modal state
const [showModal, setShowModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Given a large string, it will return a string with the first and last
// four characters.
const cutString = (str) => {
try {
const subTxid = str.slice(0, 4)
const subTxid2 = str.slice(-4)
return `${subTxid}...${subTxid2}`
} catch (err) {
console.warn('Error in cutString() ', err)
}
}
// REST request to get data from avax-dex
const getOffers = async () => {
try {
const options = {
method: 'GET',
url: `${SERVER}offer/list/fungible/0`,
data: {}
}
const result = await axios.request(options)
return result.data
} catch (err) {
console.warn('Error in getOffers() ', err)
}
}
const handleBuy = useCallback(async (event) => {
try {
console.log('Buy button clicked. Event: ', event)
const targetOfferEventId = event.target.id
console.log('targetOfferEventId: ', targetOfferEventId)
// Initialize modal
setShowModal(true)
setModalBody(['Generating Counter Offer...', '(This can take a couple minutes)'])
setHideSpinner(false)
setDenyClose(true)
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
offerData,
partialHex
})
console.log('eventId: ', eventId)
console.log('noteId: ', noteId)
// update output modal
const newModalBody = []
newModalBody.push('Success!')
newModalBody.push('What happens next:')
newModalBody.push('The money has not yet left your wallet! It is still under your control.')
newModalBody.push('If the sellers node is online, they will accept the Counter Offer you just generated in a few minutes.')
newModalBody.push('If the tokens never show up, you can sweep the funds back into your wallet.')
setModalBody(newModalBody)
setHideSpinner(true)
setDenyClose(false)
} catch (error) {
console.warn('Error in handleBuy() ', error)
setModalBody(['Buy failed: ', error.message])
setHideSpinner(true)
setDenyClose(false)
}
}, [appData]) // Dependencies: appData since it's used inside the callback
// Get Offer data and manipulate it for the sake of presentation.
const handleOffers = useCallback(async () => {
setIsLoading(true)
// Get raw offer data.
const offerRawData = await getOffers()
console.log('offerRawData: ', offerRawData)
if (!offerRawData || offerRawData.length === 0) {
setIsLoading(false)
setOffers([])
return
}
// Formatted Data
const formattedOffers = []
for (let i = 0; i < offerRawData.length; i++) {
const thisOffer = offerRawData[i]
// Get and format the token ID
const tokenId = thisOffer.tokenId
const smallTokenId = cutString(tokenId)
thisOffer.tokenId = (<a href={`https://token.fullstack.cash/?tokenid=${tokenId}`} target='_blank' rel='noreferrer'>{smallTokenId}</a>)
// Get and format the P2WDB ID
const nostrEventId = thisOffer.nostrEventId
const smallNostrEventId = cutString(nostrEventId)
thisOffer.smallNostrEventId = smallNostrEventId
thisOffer.button = (<Button text='Buy' variant='success' size='lg' id={nostrEventId} onClick={handleBuy}>Buy</Button>)
// thisOffer.p2wdbHash = (<a href={`https://p2wdb.fullstack.cash/entry/hash/${p2wdbHash}`} target='_blank' rel='noreferrer'>{smallP2wdbHash}</a>)
// Convert sats to BCH, and then calculate cost in USD.
const bchjs = appData.wallet.bchjs
const rateInSats = parseInt(thisOffer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice
const priceStr = `$${usdPrice.toFixed(3)}`
thisOffer.usdPrice = priceStr
formattedOffers.push(thisOffer)
}
setOffers(formattedOffers)
setIsLoading(false)
}, [appData, handleBuy])
useEffect(() => {
// Retrieve initial offer data
handleOffers()
// Get data and update the table periodically.
const interval = setInterval(() => {
handleOffers()
}, 30000)
// Cleanup interval on component unmount
return () => clearInterval(interval)
}, [handleOffers]) // Empty dependency array means this effect runs once on mount
const heading = 'Generating Counter Offer...'
return (
<>
{showModal && (
<WaitingModal
heading={heading}
body={modalBody}
hideSpinner={hideSpinner}
denyClose={denyClose}
/>
)}
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
{!isLoading && (
<DatatableWrapper body={offers} headers={TABLE_HEADERS}>
<Table>
<TableHeader />
<TableBody />
</Table>
</DatatableWrapper>
)}
</Col>
</Row>
{isLoading && (
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Spinner animation='border' />
</Col>
</Row>
)}
</Container>
</>
)
}
export default Offers
@@ -1,77 +0,0 @@
/*
This component is a View that allows the user to select a back end server
from a list of servers.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Button } from 'react-bootstrap'
function ServerSelectView (props) {
const { appData } = props
const selectedServer = appData.serverUrl // Selected server
const servers = appData.servers // List of servers
// Update location ref after server url changes
const handleReloadServer = (event) => {
const target = event.target.id
console.log('server target: ', target)
window.location.href = `/?restURL=${target}`
}
return (
<>
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>
Select Alternative Server
</h2>
<p>
Select an alternative server below. The app will reload and use
the selected server.
</p>
</Col>
</Row>
<br />
{
/** Map all server data and render a row for each server */
servers.map((server, i) => {
// Background color for each row
const isEven = i % 2
const backgroundColor = isEven ? '#eee' : '#fff'
return (
<div key={`server-${i}`}>
<Row
style={{
padding: '25px',
backgroundColor
}}
>
<Col xs={4}>
<Button
style={{ minWidth: 120 }}
id={server.value}
onClick={handleReloadServer}
>
{selectedServer === server.value ? 'Current' : 'Select'}
</Button>
</Col>
<Col xs={8}>
{server.label}
</Col>
</Row>
</div>
)
})
}
</Container>
</>
)
}
export default ServerSelectView
+120
View File
@@ -0,0 +1,120 @@
/*
Component for signing a message with a WIF private key.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
import { faCopy } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
function SignMessage (props) {
// Convert class state to hooks
const { wallet, appUtil } = props.appData
const [sign, setSign] = useState('')
const [msg, setMsg] = useState('')
const [bchAddr] = useState(wallet.walletInfo.cashAddress)
const [slpAddr] = useState(wallet.walletInfo.slpAddress)
const [err, setErr] = useState('')
const [copied, setCopied] = useState(false)
const handleSignMessage = (event) => {
try {
event.preventDefault()
if (!msg) throw new Error('Enter a message to sign.')
const bchjs = wallet.bchjs
const wif = props.appData.wallet.walletInfo.privateKey
const sig = bchjs.BitcoinCash.signMessageWithPrivKey(wif, msg)
setSign(sig)
setErr('')
} catch (err) {
console.log('Error in handleSignMessage(): ', err)
setErr(err.message)
setSign('')
}
}
// Function to copy the value to the clipboard.
const handleCopyToClipboard = async (value) => {
appUtil.copyToClipboard(value)
// show the copied message
setCopied(true)
// hide copied message after 1 second
setTimeout(function () {
setCopied(false)
}, 1000)
}
const copyIcon = (value) => {
return <FontAwesomeIcon icon={faCopy} size='lg' onClick={() => handleCopyToClipboard(value)} style={{ cursor: 'pointer', marginLeft: '10px' }} />
}
return (
<>
<Container>
<Row>
<Col>
<p>
This view allows you cryptographically sign a message with your
wallet. These signatures are used in a wide range of applications,
such as gaining access to
the <a href='https://t.me/psf_vip' target='_blank' rel='noreferrer'>PSF VIP Telegram channel</a>.
</p>
<p>
Enter any message into the form below and click the button. This
view will generate a cryptographic signature.
</p>
</Col>
</Row>
<Row>
<Col className='text-break'>
<Form onSubmit={handleSignMessage}>
<Form.Group className='mb-3' controlId='message'>
<Form.Label><b>Enter a message to sign.</b></Form.Label>
<Form.Control type='text' placeholder='' onChange={e => setMsg(e.target.value)} />
</Form.Group>
{err && <p style={{ color: 'red', marginBottom: '10px' }}>{`Error: ${err}`}</p>}
<Button variant='primary' onClick={handleSignMessage}>
Sign Message
</Button>
</Form>
</Col>
</Row>
<br />
{sign && (
<div style={{ textAlign: 'center' }}>
<Row>
<Col>
<p>
<b>Signature:</b> {sign} {copyIcon(sign)}
</p>
<p>
<b>BCH Address:</b> {bchAddr} {copyIcon(bchAddr)}
</p>
<p>
<b>SLP Address:</b> {slpAddr} {copyIcon(slpAddr)}
</p>
</Col>
</Row>
{copied && (
<span style={{ color: 'green' }}>
Copied!
</span>
)}
</div>
)}
</Container>
</>
)
}
export default SignMessage
+169
View File
@@ -0,0 +1,169 @@
/*
This is the 'Token View'. It displays the SLP tokens in the wallet.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import TokenCard from './token-card'
import RefreshTokenBalance from './refresh-tokens'
const SlpTokens = (props) => {
const [appData, setAppData] = useState(props.appData)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [tokens, setTokens] = useState([])
const refreshTokenButtonRef = React.useRef()
// Update the tokens state when the appData changes
useEffect(() => {
setTokens(appData.bchWalletState.slpTokens)
}, [appData])
// This function is triggered when the token balance needs to be refreshed
// from the blockchain.
// This needs to happen after sending a token, to reflect the changed balance
// within the wallet app.
// This function triggers the on-click function within the refresh-tokens.js button.
const refreshTokens = async () => {
const newAppData = await refreshTokenButtonRef.current.handleRefreshTokenBalance()
setAppData(newAppData)
}
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// Fetch mutable data if it exist and get the token icon url
const fetchTokenIcon = useCallback(async (token) => {
try {
// Get the token data
const tokenData = await appData.wallet.getTokenData(token.tokenId)
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
if (!json) return false
const iconUrl = json.tokenIcon
// Return icon url
return iconUrl
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async () => {
try {
setIconsAreLoaded(false)
const tokens = appData.bchWalletState.slpTokens
setTokens(tokens) // update token state
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenIcon(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
appData.updateBchWalletState({ walletObj: { slpTokens: tokens }, appData })
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [appData, fetchTokenIcon])
// Start to load the token icons when the component is mounted
useEffect(() => {
lazyLoadTokenIcons()
}, [lazyLoadTokenIcons])
// Generate the token cards for each token in the wallet.
const generateCards = () => {
const tokens = appData.bchWalletState.slpTokens
return tokens.map(thisToken => (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
refreshTokens={refreshTokens}
/>
))
}
return (
<>
<Container>
<Row>
<Col xs={6}>
<RefreshTokenBalance
appData={appData}
ref={refreshTokenButtonRef}
lazyLoadTokenIcons={lazyLoadTokenIcons}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
<a href='https://youtu.be/f1no5-QHTr4' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{
!iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{tokens.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
</Container>
</>
)
}
export default SlpTokens
@@ -0,0 +1,98 @@
/*
This component renders as a button. When clicked, it opens a modal that
displays information about the token.
This is a functional component with as little state as possible.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
// Takes a string as input. If it matches a pattern for a link, a JSX object is
// returned with a link. Otherwise the original string is returned.
function linkIfUrl (url) {
// Convert the URL into a link if it contains 'http'
if (url?.includes('http')) {
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url?.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
url = (<a href={`https://${cid}.ipfs.dweb.link/data.json`} target='_blank' rel='noreferrer'>{url}</a>)
}
return url
}
function InfoButton (props) {
const [show, setShow] = useState(false)
const handleClose = () => {
setShow(false)
// props.instance.setState({ showModal: false })
}
const handleOpen = () => {
setShow(true)
}
// Convert the url property of the token to a link, if it matches common patterns.
let url = props.token.url
url = linkIfUrl(props.token.url)
// console.log('props.token: ', props.token)
return (
<>
<Button variant='info' onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col xs={4}><b>Ticker</b>:</Col>
<Col xs={8}>{props.token.ticker}</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.name}</Col>
</Row>
<Row>
<Col xs={4}><b>Token ID</b>:</Col>
<Col xs={8} style={{ wordBreak: 'break-all' }}>
<a href={`https://token.fullstack.cash/?tokenid=${props.token.tokenId}`} target='_blank' rel='noreferrer'>
{props.token.tokenId}
</a>
</Col>
</Row>
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
@@ -0,0 +1,100 @@
/*
This component is displayed as a button. When clicked, it displays a modal
with a spinny gif, while the wallets SLP token list is updated from the
blockchain and psf-slp-indexer.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
import WaitingModal from '../../waiting-modal'
function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons }) {
const [appData, setAppData] = useState(initialAppData)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [hideWaitingModal, setHideWaitingModal] = useState(true)
// Add a new line to the waiting modal.
const addToModal = (inStr) => {
setModalBody(prevBody => [...prevBody, inStr])
}
// Update the balance of the wallet.
const handleRefreshTokenBalance = useCallback(async () => {
try {
// Throw up the waiting modal
setHideWaitingModal(false)
addToModal('Updating token balance...')
// Get handles on app data.
const walletState = appData.bchWalletState
const wallet = appData.wallet
// Update the wallet UTXOs
await wallet.initialize()
const tokenList = await wallet.listTokens()
// Copy tokens from old token state.
for (let i = 0; i < tokenList.length; i++) {
const thisToken = tokenList[i]
// Look through the existing wallet state for the matching token.
const existingToken = walletState.slpTokens.filter(x => x.tokenId === thisToken.tokenId)
// If the current wallet state has an icon, copy it over.
if (existingToken[0] && existingToken[0].icon) {
thisToken.icon = existingToken[0].icon
}
}
// Update the wallet state.
walletState.slpTokens = tokenList
appData.updateBchWalletState({ walletObj: walletState, appData })
const newAppData = { ...appData, bchWalletState: walletState }
// Update state
setHideWaitingModal(true)
setAppData(newAppData)
setModalBody([])
// Lazy load icons for any new tokens.
await lazyLoadTokenIcons()
return newAppData
} catch (err) {
console.error('Error while trying to update BCH balance: ', err)
setModalBody([`Error: ${err.message}`])
setHideSpinner(true)
}
}, [appData, lazyLoadTokenIcons])
// add a ref to the handleRefreshBalance function
// This is used to call the function from the parent component.
useEffect(() => {
if (ref && !ref.current) ref.current = { handleRefreshTokenBalance }
}, [ref, handleRefreshTokenBalance])
return (
<>
<Button variant='success' onClick={handleRefreshTokenBalance}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
{!hideWaitingModal && (
<WaitingModal
heading='Refreshing Token List'
body={modalBody}
hideSpinner={hideSpinner}
/>
)}
</>
)
}
export default RefreshTokenBalance
@@ -0,0 +1,230 @@
/*
This component renders as a button. When clicked, it opens up a modal
for sending a quantity of tokens.
This component requires state, because it's a complex form that is being manipulated
by the user.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Form, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane, faPaste } from '@fortawesome/free-solid-svg-icons'
function SendTokenButton ({ token, appData, refreshTokens }) {
// Convert class state to useState hooks
const [showAddrWarning, setShowAddrWarning] = useState(false)
const [showModal, setShowModal] = useState(false)
const [statusMsg, setStatusMsg] = useState('')
const [hideSpinner, setHideSpinner] = useState(true)
const [shouldRefreshOnModalClose, setShouldRefreshOnModalClose] = useState(false)
const [sendToAddress, setSendToAddress] = useState('')
const [sendQtyStr, setSendQtyStr] = useState('')
const [dialogFinished, setDialogFinished] = useState(true)
// Handler functions
const handleShowModal = () => setShowModal(true)
const handleCloseModal = async () => {
if (!dialogFinished) return
if (shouldRefreshOnModalClose) {
setShowModal(false)
setShouldRefreshOnModalClose(false)
setStatusMsg('')
await refreshTokens()
} else {
setShowModal(false)
setStatusMsg('')
setSendToAddress('')
setSendQtyStr('')
}
}
const handleUpdateSendToAddr = (event) => {
const value = event.target.value
setSendToAddress(value)
setShowAddrWarning(value.includes('bitcoincash'))
}
const handleGetMax = () => {
setSendQtyStr(token.qty)
}
// Click handler that fires when the user clicks the 'Send' button.
const handleSendTokens = async (e) => {
e.preventDefault()
try {
setStatusMsg('Preparing to send tokens...')
setHideSpinner(false)
setDialogFinished(false)
setShowAddrWarning(false)
// Validate the quantity
const qty = parseFloat(sendQtyStr)
if (isNaN(qty)) throw new Error('Invalid send quantity')
const wallet = appData.wallet
const bchjs = wallet.bchjs
// Validate the address
let addr = sendToAddress
if (addr.includes('simpleledger')) {
addr = bchjs.SLP.Address.toCashAddress(addr)
}
if (!addr.includes('bitcoincash')) throw new Error('Invalid address')
let infoStr = 'Updating UTXOs...'
setStatusMsg(infoStr)
await wallet.getUtxos()
const receiver = [{
address: addr,
tokenId: token.tokenId,
qty
}]
infoStr = 'Generating and broadcasting transaction...'
setStatusMsg(infoStr)
const txid = await wallet.sendTokens(receiver, 3)
console.log(`Token sent. TXID: ${txid}`)
setStatusMsg(<p>Success! <a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>See on Block Explorer</a></p>)
setHideSpinner(true)
setSendQtyStr('')
setSendToAddress('')
setShouldRefreshOnModalClose(true)
setDialogFinished(true)
} catch (err) {
console.error('Error in handleSendTokens(): ', err)
setStatusMsg(`Error sending tokens: ${err.message}`)
setHideSpinner(true)
setDialogFinished(true)
}
}
// Load address from clipboard
const pasteFromClipboard = async () => {
try {
const address = await appData.appUtil.readFromClipboard()
setSendToAddress(address)
} catch (err) {
console.warn('Error pasting from clipboard: ', err)
}
}
// Modal JSX
const getModal = () => {
return (
<Modal show={showModal} size='lg' onHide={handleCloseModal}>
<Modal.Header closeButton>
<Modal.Title><FontAwesomeIcon icon={faPaperPlane} size='lg' /> Send Tokens: <span style={{ color: 'red' }}>{token.ticker}</span></Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
{/* ... existing Modal.Body content ... */}
<Row>
<Col style={{ textAlign: 'center' }}>
<b>SLP Address:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form onSubmit={(e) => e.preventDefault()}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
placeholder='simpleledger:qqlrzp23w08434twmvr4fxw672whkjy0pyxpgpyg0n'
onChange={handleUpdateSendToAddr}
value={sendToAddress}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<FontAwesomeIcon
style={{ cursor: 'pointer' }}
icon={faPaste}
size='lg'
onClick={pasteFromClipboard}
/>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<b>Amount:</b>
</Col>
</Row>
<Row>
<Col xs={10}>
<Form style={{ paddingBottom: '10px' }} onSubmit={handleSendTokens}>
<Form.Group controlId='formBasicEmail' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
onChange={e => setSendQtyStr(e.target.value)}
value={sendQtyStr}
/>
</Form.Group>
</Form>
</Col>
<Col xs={2}>
<Button onClick={handleGetMax}>Max</Button>
</Col>
</Row>
<br />
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={handleSendTokens}>Send</Button>
</Col>
</Row>
<br />
{showAddrWarning && (
<>
<Row>
<Col style={{ textAlign: 'center' }}>
<p style={{ color: 'orange' }}>
<b>Warning</b>: Careful! Not all Bitcoin Cash wallets are token-aware.
If you send this token to a wallet that is not
token-aware, it could be burned. It's best practice to
only send tokens to 'simpleledger' addresses and not
'bitcoincash' addresses.
</p>
</Col>
</Row>
<br />
</>
)}
<Row>
<Col xs={10}>
{statusMsg}
</Col>
<Col xs={2}>
{!hideSpinner && <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
return (
<>
<Button variant='info' onClick={handleShowModal}>Send</Button>
{showModal && getModal()}
</>
)
}
export default SendTokenButton
@@ -0,0 +1,85 @@
/*
This Card component summarizes an SLP token.
if a token icon does not exist or cant be loaded , then display a default icon from Jdenticon library.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import SendTokenButton from './send-token-button'
function TokenCard (props) {
const { token, hideSendBtn } = props
const [icon, setIcon] = useState(token.icon)
// Update icon state every token.icon changes
useEffect(() => {
setIcon(token.icon)
}, [token.icon])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center' }}>
<h4>{props.token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{props.token.name}
</Col>
</Row>
<br />
<Row>
<Col>Balance:</Col>
<Col>{props.token.qty}</Col>
</Row>
<br />
<Row>
<Col>
<InfoButton token={props.token} />
</Col>
{!hideSendBtn && (
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
)}
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default TokenCard
+238
View File
@@ -0,0 +1,238 @@
/*
This Sweep component allows users to sweep a private key and transfer any
BCH or SLP tokens into their wallet.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Form, Button, Modal, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
import Sweeper from 'bch-token-sweep'
// let _this
const SweepWif = (props) => {
const { appData } = props
console.log('appData', appData)
const [wifToSweep, setWifToSweep] = React.useState('')
const [showModal, setShowModal] = React.useState(false)
const [statusMsg, setStatusMsg] = React.useState('')
const [hideSpinner, setHideSpinner] = React.useState(false)
// shouldRefreshOnModalClose: false
// Helper function to validate WIF
const validateWIF = (WIF) => {
if (typeof WIF !== 'string') return false
if (WIF.length !== 52) return false
if (WIF[0] !== 'L' && WIF[0] !== 'K') return false
return true
}
// Update wallet state function
const updateWalletState = async () => {
const wallet = appData.wallet
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
await wallet.initialize()
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData })
}
// Handle sweep function
const handleSweep = async (e) => {
if (e) {
e.preventDefault()
}
try {
console.log(`Sweeping this WIF: ${wifToSweep}`)
// Set modal initial state
setShowModal(true)
setHideSpinner(false)
setStatusMsg('')
// Input validation
const isWIF = validateWIF(wifToSweep)
if (!isWIF) {
setHideSpinner(true)
setStatusMsg(<b style={{ color: 'red' }}>Input is not a WIF private key.</b>)
return
}
try {
const walletWif = appData.wallet.walletInfo.privateKey
const toAddr = appData.wallet.slpAddress
// Instance the Sweep library
const sweep = new Sweeper(wifToSweep, walletWif, appData.wallet)
await sweep.populateObjectFromNetwork()
// Constructing the sweep transaction
const hex = await sweep.sweepTo(toAddr)
const txid = await appData.wallet.ar.sendTx(hex)
// Generate status message
const newStatusMsg = (
<>
<p>Sweep succeeded!</p>
<p>Transaction ID: {txid}</p>
<p>
<a href={`https://blockchair.com/bitcoin-cash/transaction/${txid}`} target='_blank' rel='noreferrer'>
TX on Blockchair BCH Block Explorer
</a>
</p>
<p>
<a href={`https://token.fullstack.cash/transactions/?txid=${txid}`} target='_blank' rel='noreferrer'>
TX on token explorer
</a>
</p>
</>
)
setHideSpinner(true)
setStatusMsg(newStatusMsg)
setWifToSweep('')
await updateWalletState()
} catch (err) {
setHideSpinner(true)
setStatusMsg(<b style={{ color: 'red' }}>{`Error: ${err.message}`}</b>)
}
} catch (err) {
console.error('Error in handleSweep(): ', err)
}
}
const handleCancelCounterOffers = async () => {
try {
console.log('Executing handleCancelCounterOffers()')
// Set modal initial state
// setShowModal(true)
// setHideSpinner(false)
// setStatusMsg('')
// Get the keypair that holds Counter Offer UTXOs.
const bchDexLib = appData.dexLib
const keyPair = await bchDexLib.take.util.getKeyPair(1)
const wif = keyPair.wif
// console.log(`WIF: ${wif}`)
// Sweep the private key holding the Counter Offer UTXOs.
setWifToSweep(wif)
await handleSweep()
} catch (err) {
console.error('Error in handleCancelCounterOffers(): ', err)
}
}
// Modal component
const getModal = () => (
<Modal show={showModal} size='lg' onHide={() => setShowModal(false)}>
<Modal.Header closeButton>
<Modal.Title>Sweeping...</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
{!hideSpinner && (
<Col style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ marginRight: '10px' }}>Sweeping private key...</span> <Spinner animation='border' />
</Col>
)}
</Row>
<br />
{statusMsg && (
<Row>
<Col style={{ textAlign: 'center' }}>{statusMsg}</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
return (
<>
<Container>
<Row>
<Col style={{ textAlign: 'right' }}>
<a href='https://youtu.be/QW9xixHaEJE' target='_blank' rel='noreferrer'>
<FontAwesomeIcon icon={faCircleQuestion} size='lg' />
</a>
</Col>
</Row>
<Row>
<Col>
<p>
This View is used to 'sweep' a private key. This will transfer
any BCH or SLP tokens from a paper wallet to your web wallet.
Paper wallets are used to store BCH and tokens. You
can <a href='https://paperwallet.fullstack.cash/' target='_blank' rel='noreferrer'>generate paper wallets here</a>.
</p>
<p>
Paste the private key of a paper wallet below and click the button
to sweep the funds. The private key must be in WIF format. It will
start with the letter 'K' or 'L'.
</p>
</Col>
</Row>
<Row>
<Col>
<Form onSubmit={handleSweep}>
<Form.Group controlId='formWif' style={{ textAlign: 'center' }}>
<Form.Control
type='text'
placeholder='KzJqZxi5XSo36woCy7MFVNRPDpfp8x8FpkhRvrErKBBrDXRVY9Ft'
onChange={(e) => setWifToSweep(e.target.value)}
value={wifToSweep}
/>
</Form.Group>
</Form>
</Col>
</Row>
<br />
<Row style={{ textAlign: 'center' }}>
<Col>
<Button variant='info' onClick={handleSweep}>
Sweep
</Button>
</Col>
</Row>
<br />
<br />
<hr />
<Row>
<Col>
<p>
When a Buy order is created, the coins (UTXO) to pay for it are
moved to a secondary address. Clicking the button below will
sweep those funds back into this main wallet. This will also
cancel/invalidate all open Counter Offers that you've created.
</p>
</Col>
</Row>
<Row style={{ textAlign: 'center' }}>
<Col>
<Button onClick={handleCancelCounterOffers}>
Sweep DEX Trading Wallet
</Button>
</Col>
</Row>
</Container>
{showModal && getModal()}
</>
)
}
export default SweepWif
+126
View File
@@ -0,0 +1,126 @@
/*
Search in the provided cid of a mutable data, get the data from the user data and show it
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Container, Row, Col, Spinner, Carousel } from 'react-bootstrap'
import ReactMarkdown from 'react-markdown'
function UserDataReview (props) {
const appData = props.appData
const [media, setMedia] = useState([])
const [markdown, setMarkdown] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tokenData, setTokenData] = useState(null)
// Get the id parameter from the URL
const { tokenId } = useParams()
useEffect(() => {
const loadData = async () => {
try {
const tokenData = await appData.wallet.getTokenData(tokenId)
setTokenData(tokenData)
setLoading(true)
if (!tokenData.mutableData) throw new Error('Mutable data not found')
const cid = parseCid(tokenData.mutableData)
const { json } = await appData.wallet.cid2json({ cid })
const userDataString = json.userData
if (userDataString) {
const userData = JSON.parse(userDataString)
setMedia(userData.media)
setMarkdown(userData.markdown)
}
} catch (error) {
setError(error.message)
}
setLoading(false)
}
loadData()
}, [tokenId, appData.wallet])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
return (
<Container>
<Row>
<Col>
{/** Error message */}
{error && <p className='text-danger'>{error}</p>}
{/** Loading spinner */}
{loading &&
(
<div className='text-center my-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{/** User data */}
{!loading && !error && (
<div className='text-center'>
{/** Name */}
<h1>{tokenData.genesisData.name}</h1>
{/** Media Content Carousel */}
{media && media.length > 0
? (
<div className='my-5 '>
<Carousel>
{media.map((item, index) => (
<Carousel.Item key={index}>
<img
className='d-block w-100'
src={item.url}
alt={`Review media ${index + 1}`}
style={{ height: '400px', objectFit: 'contain' }}
/>
<Carousel.Caption>
<p>Image {index + 1} of {media.length}</p>
</Carousel.Caption>
</Carousel.Item>
))}
</Carousel>
<style>{'.carousel-control-next,.carousel-control-prev, .carousel-indicators {filter: invert(100%); } '}
</style>
</div>
)
: (
<p className='mt-3'>No media content available</p>
)}
{/** Markdown Content */}
{markdown
? (
<div className='markdown-content my-5 text-center'>
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
)
: (
<p className='mt-3'>No content available</p>
)}
</div>
)}
</Col>
</Row>
</Container>
)
}
export default UserDataReview
+30 -42
View File
@@ -3,25 +3,25 @@
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import React, { useEffect } from 'react'
import { Container, Row, Col } from 'react-bootstrap'
// Local libraries
import config from '../../config'
import Memo from './get-cid'
// import Memo from './get-cid'
function Footer (props) {
const [ipfsCid, setIpfsCid] = useState(config.ipfsCid)
// const [ipfsCid, setIpfsCid] = useState(config.ipfsCid)
const wallet = props.appData.wallet
// Retrieve the most up-to-date CID for the app on Filecoin from the BCH blockchain.
useEffect(() => {
async function fetchData () {
try {
const hash = await getUpdatedUrl(wallet)
if (hash) {
setIpfsCid(hash)
}
// const hash = await getUpdatedUrl(wallet)
// if (hash) {
// setIpfsCid(hash)
// }
} catch (err) {
console.error('Error trying to retrieve Filecoin CID for the app from the BCH blockchain.')
}
@@ -32,56 +32,44 @@ function Footer (props) {
return (
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h6>Site Mirrors</h6>
<ul>
<li>
<a href={config.ghPagesUrl} target='_blank' rel='noreferrer'>GitHub Pages</a>
</li>
<li>
<a href={`https://${ipfsCid}.ipfs.dweb.link/`} target='_blank' rel='noreferrer'>Filecoin</a>
</li>
</ul>
</Col>
<Col>
<h6>Source Code</h6>
<ul>
<li>
<a href={config.ghRepo} target='_blank' rel='noreferrer'>GitHub</a>
</li>
<li>
<a href={config.radicleUrl} target='_blank' rel='noreferrer'>Radicle</a>
</li>
</ul>
</Col>
<Col />
</Row>
</Container>
)
}
async function getUpdatedUrl (wallet) {
try {
// Exit if the wallet is not initialized.
if (!wallet) return
// async function getUpdatedUrl (wallet) {
// try {
// // Exit if the wallet is not initialized.
// if (!wallet) return
// Initialize the memo library for retrieving data from the BCH blockchain.
const memo = new Memo({ bchAddr: config.appBchAddr })
await memo.initialize(wallet)
const hash = await memo.findHash()
// // Initialize the memo library for retrieving data from the BCH blockchain.
// const memo = new Memo({ bchAddr: config.appBchAddr })
// await memo.initialize(wallet)
// const hash = await memo.findHash()
if (!hash) {
console.error(
`Could not find IPFS hash in transactions for address ${config.appBchAddr}`
)
return false
}
// console.log(`latest IPFS hash: ${hash}`)
// if (!hash) {
// console.error(
// `Could not find IPFS hash in transactions for address ${config.appBchAddr}`
// )
// return false
// }
// // console.log(`latest IPFS hash: ${hash}`)
return hash
} catch (err) {
console.log('Error in getUpdatedUrl(): ', err)
}
}
// return hash
// } catch (err) {
// console.log('Error in getUpdatedUrl(): ', err)
// }
// }
export default Footer
+117 -37
View File
@@ -15,8 +15,7 @@ import Logo from './psf-logo.png'
function NavMenu (props) {
// Get the current path
const { currentPath } = props.appData
const { currentPath, bchWalletState, isSingleView } = props.appData
// Navbar state
const [expanded, setExpanded] = useState(false)
@@ -25,49 +24,130 @@ function NavMenu (props) {
// Collapse the navbar
setExpanded(false)
}
const goToHome = () => {
if (isSingleView) {
window.location.href = '/'
}
}
return (
<>
<Navbar expanded={expanded} onToggle={setExpanded} expand='xxxl' bg='dark' variant='dark' style={{ paddingRight: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }}>
<Navbar.Brand href='#home' style={{ paddingLeft: '20px' }} onClick={goToHome}>
<Image src={Logo} thumbnail width='50' />{' '}
PSF Web3 Demo
DEX
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<NavLink
className={(currentPath === '/balance' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
{!isSingleView && <Navbar.Toggle aria-controls='responsive-navbar-nav' />}
{!isSingleView && (
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
<NavLink
className={(currentPath === '/nfts-for-sale' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/nfts-for-sale'
onClick={handleClickEvent}
>
NFTs
</NavLink>
>
Wallet
</NavLink>
<NavLink
className={currentPath === '/Placeholder2' ? 'nav-link-active' : 'nav-link-inactive'}
to='/Placeholder2'
onClick={(e) => handleClickEvent(1)}
>
Placeholder2
</NavLink>
<NavLink
className={currentPath === '/placeholder3' ? 'nav-link-active' : 'nav-link-inactive'}
to='/placeholder3'
onClick={handleClickEvent}
>
Placeholder 3
</NavLink>
</Nav>
</Navbar.Collapse>
<NavLink
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<hr />
<NavLink
className={currentPath === '/global-feed' ? 'nav-link-active' : 'nav-link-inactive'}
to='/feeds'
onClick={handleClickEvent}
>
Feeds
</NavLink>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/profile' ? 'nav-link-active' : 'nav-link-inactive'}
to={`/profile/${bchWalletState?.nostrKeyPair?.npub}`}
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
<NavLink
className={currentPath === '/content-creators' ? 'nav-link-active' : 'nav-link-inactive'}
to='/content-creators'
onClick={handleClickEvent}
>
Content Creators
</NavLink>
<hr />
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
>
Wallet
</NavLink>
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep'
onClick={handleClickEvent}
>
Sweep
</NavLink>
<NavLink
className={(currentPath === '/sign') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sign'
onClick={handleClickEvent}
>
Sign
</NavLink>
<NavLink
className={currentPath === '/configuration' ? 'nav-link-active' : 'nav-link-inactive'}
to='/configuration'
onClick={handleClickEvent}
>
Configuration
</NavLink>
</Nav>
</Navbar.Collapse>
)}
</Navbar>
</>
)
+1 -1
View File
@@ -20,7 +20,7 @@ export function UninitializedView (props = {}) {
denyClose={appData.denyClose}
/>
{
props.asyncInitFinished
appData.asyncInitFinished
? <AppBody menuState={100} wallet={appData.wallet} appData={appData} />
: null
}
+13 -2
View File
@@ -11,8 +11,19 @@ const config = {
// Backup Info that goes into the Footer.
ghPagesUrl: 'https://permissionless-software-foundation.github.io/react-bootstrap-web3-spa/',
ghRepo: 'https://github.com/Permissionless-Software-Foundation/react-bootstrap-web3-spa',
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc'
ghRepo: 'https://github.com/Permissionless-Software-Foundation/bch-dex-taker-v2',
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash',
// dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info',
nostrRelays: [
'wss://nostr-relay.psfoundation.info',
'wss://nos.lol',
'wss://relay.damus.io'
]
}
+53 -13
View File
@@ -1,20 +1,34 @@
import { useState } from 'react'
import { useQueryParam, StringParam } from 'use-query-params'
// import { useQueryParam, StringParam } from 'use-query-params'
import useLocalStorageState from 'use-local-storage-state'
import AppUtil from '../util'
import NostrQueries from '../services/nostr-queries'
import { useLocation } from 'react-router-dom'
function useAppState () {
const location = useLocation()
// Get the CashStack URL from query parameter or use default
let [restURL] = useQueryParam('restURL', StringParam)
if (!restURL) restURL = 'https://free-bch.fullstack.cash'
// Load Local storage Data
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', {
ssr: true,
defaultValue: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
},
nftData: {},
lastFeedTab: 'feed'
})
const [serverUrl, setServerUrl] = useState(restURL)
console.log('lsState: ', lsState)
// Initialize data states
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
const [menuState, setMenuState] = useState(0)
const [wallet, setWallet] = useState(false)
const [servers, setServers] = useState([])
const [dexLib, setDexLib] = useState(false)
const [nostr, setNostr] = useState(false)
const [lastFeedTab, setLastFeedTab] = useState(lsState.lastFeedTab || 'feed')
// Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -26,20 +40,24 @@ function useAppState () {
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isSingleView, setIsSingleView] = useState(false)
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
// The wallet state makes this a true progressive web app (PWA). As
// balances, UTXOs, and tokens are retrieved, this state is updated.
// properties are enumerated here for the purpose of documentation.
// Local storage
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState', {
ssr: true,
defaultValue: {}
})
console.log('lsState: ', lsState)
// const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState', {
// ssr: true,
// defaultValue: {}
// })
// console.log('lsState: ', lsState)
const removeLocalStorageItem = removeItem
const updateLocalStorage = (lsObj) => {
// console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
console.log(`updateLocalStorage() input: ${JSON.stringify(lsObj, null, 2)}`)
// Progressively overwrite the LocalStorage state.
const newObj = Object.assign({}, lsState, lsObj)
@@ -86,6 +104,17 @@ function useAppState () {
}
}
// Update NFT for sale tokens data
function updateNFTCachedData (tokenId, data) {
const allCacheData = nftForSaleCacheData // Get all cache data
const cacheData = allCacheData[tokenId] || {} // Get cache data for the tokenId
const newCacheData = Object.assign({}, cacheData, data) // Merge the new data with the cache data
allCacheData[tokenId] = newCacheData // Update the cache data
setNftForSaleCacheData(allCacheData) // Update the state
updateLocalStorage({ nftData: allCacheData }) // Update the local storage
}
return {
serverUrl,
setServerUrl,
@@ -117,8 +146,19 @@ function useAppState () {
updateLocalStorage,
updateBchWalletState,
appUtil: new AppUtil(),
currentPath: location.pathname
currentPath: location.pathname,
dexLib,
setDexLib,
nostr,
setNostr,
nftForSaleCacheData,
setNftForSaleCacheData,
updateNFTCachedData,
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView,
nostrQueries: new NostrQueries()
}
}
+129 -8
View File
@@ -4,9 +4,16 @@
// Global npm libraries
import axios from 'axios'
import BchDexLib from 'bch-dex-lib'
// Local libraries
import GistServers from './gist-servers'
import Nostr from './nostr'
import P2WDB from 'p2wdb'
import { base58_to_binary as base58ToBinary } from 'base58-js'
import { bytesToHex } from '@noble/hashes/utils' // already an installed dependency
import { getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
class AsyncLoad {
constructor () {
@@ -56,8 +63,14 @@ class AsyncLoad {
await wallet.walletInfoPromise
await wallet.initialize()
console.log('starting to update wallet state.')
// Get Nostr key pair from WIF
const nostrKeyPair = this.nostrKeyPairFromWIF(wallet.walletInfo.privateKey)
console.log('nostrKeyPair: ', nostrKeyPair)
const walletInfo = wallet.walletInfo
walletInfo.nostrKeyPair = nostrKeyPair
// Update the state of the wallet.
appData.updateBchWalletState({ walletObj: wallet.walletInfo, appData })
appData.updateBchWalletState({ walletObj: walletInfo, appData })
console.log('finished updating wallet state.')
// Save the mnemonic to local storage.
if (!mnemonic) {
@@ -80,6 +93,10 @@ class AsyncLoad {
// Get the BCH balance of the wallet.
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
await wallet.walletInfoPromise
await wallet.initialize()
// console.log(`mnemonic: ${wallet.walletInfo.mnemonic}`)
// Update the state of the wallet with the balances
updateBchWalletState({ walletObj: { bchBalance }, appData })
@@ -112,13 +129,6 @@ class AsyncLoad {
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
// console.log('slpTokens: ', slpTokens)
// Add an icon property to each token.
slpTokens.map(x => {
x.icon = ''// (<Jdenticon size='100' value={x.tokenId} />)
x.iconNeedsDownload = true
return true
})
console.log('slpTokens: ', slpTokens)
// Update the state of the wallet with the balances
@@ -203,6 +213,117 @@ class AsyncLoad {
return defaultOptions
}
}
// Load the BchDexLib library.
getDexLib (inObj = {}) {
try {
const { bchWallet, p2wdbRead, p2wdbWrite } = inObj
const dexLib = new BchDexLib({ bchWallet, p2wdbRead, p2wdbWrite })
return dexLib
} catch (error) {
console.error('Error getting DexLib', error)
throw error
}
}
// Load the Nostr library.
getNostrLib (inObj = {}) {
try {
const { bchWallet } = inObj
const nostrLib = new Nostr({ bchWallet })
return nostrLib
} catch (error) {
console.error('Error in getNostrLib', error)
throw error
}
}
// Load the P2WDB library.
getP2WDBLib (inObj = {}) {
try {
const { bchWallet } = inObj
const p2wdbRead = new P2WDB.Read()
const p2wdbWrite = new P2WDB.Write({ bchWallet })
return { p2wdbRead, p2wdbWrite }
} catch (error) {
console.error('Error in getP2WDBLib', error)
throw error
}
}
// Get Nostr key pair from WIF
nostrKeyPairFromWIF (WIF) {
if (!WIF) return
// Extract the privaty key from the WIF, using this guide:
// https://learnmeabitcoin.com/technical/keys/private-key/wif/
const wifBuf = base58ToBinary(WIF)
const privBuf = wifBuf.slice(1, 33)
// console.log('privBuf: ', privBuf)
// Convert the private key to a hex string
const privHex = bytesToHex(privBuf)
// Convert the private key to a Nostr NSEC key
const nsec = nip19.nsecEncode(privBuf)
// Get the public key hex string from the private key
const pubHex = getPublicKey(privBuf)
// Convert the public key to a Nostr NPUB key
const npub = nip19.npubEncode(pubHex)
return {
privHex,
pubHex,
nsec,
npub
}
}
// Initialize the BCH wallet without initialize() promise
async initStarterWallet (restURL, mnemonic, appData) {
try {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true
}
let wallet
if (mnemonic) {
// Load the wallet from the mnemonic, if it's available from local storage.
wallet = new this.BchWallet(mnemonic, options)
} else {
// Generate a new mnemonic and wallet.
wallet = new this.BchWallet(null, options)
}
await wallet.walletInfoPromise
// Get Nostr key pair from WIF
const nostrKeyPair = this.nostrKeyPairFromWIF(wallet.walletInfo.privateKey)
const walletInfo = wallet.walletInfo
walletInfo.nostrKeyPair = nostrKeyPair
// Update the state of the wallet.
appData.updateBchWalletState({ walletObj: walletInfo, appData })
// Save the mnemonic to local storage.
if (!mnemonic) {
const newMnemonic = wallet.walletInfo.mnemonic
appData.updateLocalStorage({ mnemonic: newMnemonic })
}
this.wallet = wallet
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
throw error
}
}
}
function sleep (ms) {
+235
View File
@@ -0,0 +1,235 @@
/**
* Nostr class for query into relay pools
*
*/
import config from '../config'
import { RelayPool } from 'nostr'
import * as nip19 from 'nostr-tools/nip19'
export default class NostrQueries {
constructor () {
this.relays = config.nostrRelays
}
npubToHex (npub) {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
return pubHex
}
// Load profile from nostr relays
// It uses multiple relays. It will exit after the first successful retrieval
// from any relay. If one relay fails, it will move on to the next one.
async getProfile (pubHex) {
try {
for (let i = 0; i < this.relays.length; i++) {
const profile = await new Promise((resolve) => {
const relay = this.relays[i]
// const pool = RelayPool(config.nostrRelays)
const pool = RelayPool([relay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [0], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
resolve(false)
})
pool.on('event', (relay, subId, ev) => {
try {
const profile = JSON.parse(ev.content)
// console.log('profile', profile)
console.log(`Profile found for ${pubHex} at ${relay.url}`)
resolve(profile)
} catch (error) {
resolve(false)
}
relay.close()
})
})
// Stop looking for profile if found
if (profile) {
return profile
}
}
} catch (error) {
console.warn(error)
}
}
// Get Feeds by user pubkey
async getUserFeeds (pubHex) {
try {
let feeds = await new Promise((resolve) => {
let list = []
let closedRelays = 0
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { limit: 5, kinds: [1], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
list = [...list, ev]
})
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get global feeds
async getGlobalFeeds () {
try {
let feeds = await new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('REQ', { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === config.nostrRelays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('post retrieved from ', relay.url, ev.sig)
list = [...list, ev]
})
})
// Remove duplicated feeds
feeds = feeds.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Sort from newest to oldest
feeds.sort((a, b) => b.created_at - a.created_at)
// console.log('feeds', feeds)
return feeds
} catch (error) {
console.warn(error)
}
}
// Get follow list by pubkey
async getFollowList (pubHex) {
return new Promise((resolve, reject) => {
let list = []
let closedRelays = 0
const pool = RelayPool(config.nostrRelays)
// const pool = RelayPool([config.nostrRelay])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 1, kinds: [3], authors: [pubHex] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === this.relays.length) {
resolve(list)
}
})
pool.on('event', (relay, subId, ev) => {
// console.log('Received event:', ev)
// Merge list received from all relays
list = [...list, ...ev.tags]
})
})
}
// Get event likes
async getPostLikes (postId) {
try {
let likesRes = await new Promise((resolve) => {
const likes = []
let closedRelays = 0
const pool = RelayPool(this.relays)
pool.on('open', relay => {
relay.subscribe('subid', { kinds: [7], '#e': [postId] })
})
pool.on('eose', relay => {
relay.close()
closedRelays++
// Resolve list if all relays are closed
if (closedRelays === this.relays.length) {
resolve(likes)
}
})
pool.on('event', (relay, subId, ev) => {
try {
// Count likes
if (ev.content === '+' || ev.content === '-') {
likes.push(ev)
}
} catch (error) {
// skip error
}
})
})
// Remove duplicated events
likesRes = likesRes.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value.id === val.id)
return existingIndex === i
})
// Get likes
const likesArr = likesRes.filter((val, i, list) => {
return val.content === '+'
})
// Get dislikes
const dislikesArr = likesRes.filter((val, i, list) => {
return val.content === '-'
})
// For every user dislike remove a user like from the array
for (let i = 0; i < dislikesArr.length; i++) {
const disLike = dislikesArr[i]
const likeExist = likesArr.findIndex(val => val.pubkey === disLike.pubkey)
if (likeExist >= 0) likesArr.splice(likeExist, 1)
}
// Return array of likes.
return likesArr
} catch (error) {
console.warn(error)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
Implementing nostr natively in the browser, rather than using a library
intended for node.js
*/
// Global npm libraries
// import { finalizeEvent } from '@chris.troutner/nostr-tools/pure'
// import { Relay } from '@chris.troutner/nostr-tools/relay'
// import BchNostr from 'bch-nostr'
// import * as nip19 from '@chris.troutner/nostr-tools/nip19'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import BchNostr from 'bch-nostr'
import * as nip19 from 'nostr-tools/nip19'
import config from '../config/index.js'
class NostrBrowser {
constructor (localConfig = {}) {
if (!localConfig.bchWallet) {
throw new Error('Instance of minimal-slp-wallet must be passed as wallet property when instantiating the bch-dex-lib library.')
}
this.bchWallet = localConfig.bchWallet
this.bchNostr = new BchNostr({
relayWs: config.nostrRelay,
topic: config.nostrTopic
})
}
async testNostrUpload (inObj = {}) {
try {
console.log('testNostrUpload() executing.')
// console.log('this.bchWallet: ', this.bchWallet)
const { offerData, partialHex } = inObj
console.log('offerData: ', offerData)
const wif = this.bchWallet.walletInfo.privateKey
// const { privKeyBuf, nostrPubKey } =
// this.bchNostr.keys.createNostrPubKeyFromWif({ wif })
const { privKeyBuf } =
this.bchNostr.keys.createNostrPubKeyFromWif({ wif })
// Scaffold the Counter Offer from the Offer
const counterOfferData = Object.assign({}, offerData)
counterOfferData.partialTxHex = partialHex
delete counterOfferData.nostrEventId
delete counterOfferData._id
counterOfferData.offerHash = offerData.nostrEventId
// Add P2WDB specific flag for signaling that this is a new Counter Offer.
counterOfferData.dataType = 'counter-offer'
console.log(`counterOfferData: ${JSON.stringify(counterOfferData, null, 2)}`)
const nostrData = {
data: counterOfferData
}
const msg = JSON.stringify(nostrData, null, 2)
// const inObj = {
// kind: 867,
// privKeyBuf: privKeyBuf,
// nostrPubKey: nostrPubKey,
// relayWs: 'wss://nostr-relay.psfoundation.info',
// msg,
// tags: [['t', 'bch-dex-test-topic-01']]
// }
const relayWs = config.nostrRelay
const eventTemplate = {
kind: 867,
created_at: Math.floor(Date.now() / 1000),
tags: [['t', config.nostrTopic]],
content: msg
}
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privKeyBuf)
// console.log('signedEvent: ', signedEvent)
const eventId = signedEvent.id
// Connect to a relay.
const relay = await Relay.connect(relayWs, {
/* global WebSocket */
webSocket: WebSocket
})
// console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
await relay.publish(signedEvent)
// Close the connection to the relay.
relay.close()
// const eventId = await this.bchNostr.post.uploadToNostr(inObj)
const noteId = this.eventId2note(eventId)
// const eventId = 'test1'
// const noteId = 'test2'
return { eventId, noteId }
} catch (err) {
console.log('Error in nostr.js/testNostrUpload()')
throw err
}
}
// Convert an Event ID into a `noteabc..` syntax that Astral expects.
// This can be used to generate a link to Astral to display the post.
eventId2note (eventId) {
return nip19.noteEncode(eventId)
}
}
export default NostrBrowser