Compare commits

...
284 Commits
Author SHA1 Message Date
Chris Troutner cb72507bea Merge pull request #84 from Permissionless-Software-Foundation/ct-unstable
Counter Offer Cancel Button Functionality
2025-12-01 08:35:34 -08:00
Chris Troutner efb5c69010 fix(env vars): Better env var management 2025-12-01 08:33:09 -08:00
Chris Troutner 81b9c3123a fix(Counter Offers): Adding confirmation modal before canceling Counter Offer 2025-11-29 15:25:07 -08:00
Chris Troutner 69fd424745 feat(Cancel Counter Offer): Adding functionality to Cancel button in Counter Offer page 2025-11-29 12:34:42 -08:00
Chris Troutner f8c14cc1fb Merge pull request #83 from Permissionless-Software-Foundation/ct-unstable
fix(/fungible): Changing /offers to /fungible
2025-11-29 08:25:07 -08:00
Chris Troutner 2e4fd9f876 fix(/fungible): Changing /offers to /fungible 2025-11-29 08:17:00 -08:00
Chris Troutner d31a7f5e73 Merge pull request #82 from Permissionless-Software-Foundation/dh-ctr-offr-page
feat(offers): Created Counter Offers page
2025-11-25 19:01:13 -08:00
Daniel Gonzalez b973f7101a feat(offers): Created Counter Offers page 2025-11-25 20:56:03 -04:00
Chris Troutner 313df177e9 Merge pull request #80 from Permissionless-Software-Foundation/ct-unstable
Dependency Overrides
2025-11-20 05:48:03 -08:00
Chris Troutner 2c34ea5c2e fixing merge conflicts 2025-11-20 05:29:59 -08:00
Chris Troutner aa2d92bfaf Merge pull request #79 from Permissionless-Software-Foundation/dh-counter-offer-data
feat(offer): Added more Counter Offer data
2025-11-20 05:28:08 -08:00
Chris Troutner e9e9982de0 Updated package-lock 2025-11-19 19:10:26 -08:00
Chris Troutner 5a3010ffce fix(deps): Fixing dependency conflicts 2025-11-19 19:05:04 -08:00
Daniel Gonzalez 40467014f0 feat(offer): Added more Counter Offer data 2025-11-19 21:30:41 -04:00
Chris Troutner d94b3fc853 Merge pull request #78 from Permissionless-Software-Foundation/dh-counter-offers-page
feat():Counter Offers Page: Set up test environment
2025-11-17 17:24:31 -08:00
Daniel Gonzalez 51ed5601fa feat():Counter Offers Page: Set up test environment 2025-11-17 18:00:52 -04:00
Chris Troutner d72d98a2d7 Merge pull request #77 from Permissionless-Software-Foundation/ct-unstable
fix(nft card): Seller link only works if npub is defined
2025-11-14 11:15:37 -08:00
Chris Troutner 7237cb6c6a fix(nft card): Seller link only works if npub is defined 2025-11-14 11:14:55 -08:00
Chris Troutner 93c8eb42d3 Merge pull request #76 from Permissionless-Software-Foundation/dh-seller-link
feat(sell): Added link to Seller profile
2025-11-14 08:43:01 -08:00
Daniel Gonzalez fa3c71f0a1 feat(sell): Added link to Seller profile 2025-11-13 17:07:41 -04:00
Chris Troutner 34a6adbf56 Merge pull request #75 from Permissionless-Software-Foundation/nostr-api
Nostr: Switching from websockets to API
2025-11-05 09:39:59 -08:00
Chris Troutner f303b95741 fix(nostr): Better subscript management and preventing race conditions 2025-11-05 09:38:06 -08:00
Chris Troutner 55054b4e51 Fixing merge conflicts 2025-11-05 07:44:43 -08:00
Chris Troutner 9f7b11fbc4 Merge pull request #74 from Permissionless-Software-Foundation/dh-filter-posts
feat(nostr): Filter out posts from deletedPosts database
2025-11-05 07:32:44 -08:00
Chris Troutner cffeb76603 feat(Nostr): Switching to use of API from websockets 2025-11-05 07:31:54 -08:00
Daniel Gonzalez 9f6c0d3fc7 feat(nostr): Filter out posts from deletedPosts database 2025-11-04 19:39:01 -04:00
Daniel Gonzalez dfb0bca2ec moved deleted chats requests from async load to nostr queries 2025-11-04 19:31:21 -04:00
Chris Troutner efdfb7971b Merge pull request #73 from Permissionless-Software-Foundation/dh-filter-chats
feat(chat): Filter out chat messages from deletedChat database
2025-11-04 08:48:22 -08:00
Daniel Gonzalez 8c59bdf6d8 feat(chat): Filter out chat messages from deletedChat database 2025-11-03 15:12:46 -04:00
Chris Troutner 143def9164 Merge pull request #72 from Permissionless-Software-Foundation/dh-update-btn
feat(update): Added update button
2025-10-25 11:23:40 -07:00
Daniel Gonzalez b58309d82a feat(update): Added update button 2025-10-24 19:59:48 -04:00
Chris Troutner 61a5ad7166 Merge pull request #71 from Permissionless-Software-Foundation/dh-load-nft-db
feat(nft): Load NFT card from bch-dex DB
2025-10-21 12:57:18 -07:00
Daniel Gonzalez 3b1b6248ab feat(nft): Load NFT card from bch-dex DB 2025-10-20 18:38:58 -04:00
Chris Troutner cd44f28f28 Merge pull request #70 from Permissionless-Software-Foundation/ct-unstable
Syncing with upstream bch-wallet-web-spa
2025-10-19 14:55:23 -07:00
Chris Troutner 5d7994cefe Syncing with upstream bch-wallet-web-spa 2025-10-19 14:53:01 -07:00
Chris Troutner ae4916e8df Merge pull request #18 from Permissionless-Software-Foundation/ct-unstable
Ct unstable
2025-10-19 14:48:30 -07:00
Chris Troutner fe108caf33 Merge branch 'master' into ct-unstable 2025-10-19 14:47:09 -07:00
Chris Troutner 131a41fcb4 Merge pull request #17 from Permissionless-Software-Foundation/dh-down-servers
fix(server): Web Wallet needs to handle down servers
2025-10-19 14:41:43 -07:00
Daniel Gonzalez f25ac56cdb fix(server): Web Wallet needs to handle down servers 2025-10-18 15:46:29 -04:00
Chris Troutner 0fb8bc586b Merge pull request #69 from Permissionless-Software-Foundation/ct-unstable
fix(category): Using Misc as default category
2025-10-14 07:47:39 -07:00
Chris Troutner 4f8a6540e5 fix(category): Using Misc as default category 2025-10-14 07:46:21 -07:00
Chris Troutner b29ff5fb1f Merge branch 'master' into ct-unstable 2025-10-14 07:17:47 -07:00
Chris Troutner 9e576cbe83 Merge pull request #16 from Permissionless-Software-Foundation/dh-load-times
fix(load): Load BCH & SLP info in the background
2025-10-14 07:13:20 -07:00
Daniel Gonzalez e3b3b76430 fix(load): Load BCH & SLP info in the background 2025-10-13 17:34:50 -04:00
Chris Troutner f8f46fcad8 Merge pull request #68 from Permissionless-Software-Foundation/dh-categories-ui
feat(NFTs): Added categories dropdown to nfts page
2025-10-12 16:42:04 -07:00
Daniel Gonzalez 8c00d5d079 feat(NFTs): Added categories dropdown to nfts page 2025-10-11 18:00:43 -04:00
Chris Troutner 4e665cd738 Merge pull request #67 from Permissionless-Software-Foundation/dh-pagination
feat(NFTs): Added pagination controls for NFTs
2025-10-09 07:22:05 -07:00
Daniel Gonzalez af3dca0445 feat(NFTs): Added pagination controls for NFTs 2025-10-08 20:21:55 -04:00
Chris Troutner e1d1cee1f1 Merge pull request #66 from Permissionless-Software-Foundation/upstream-merge
Upstream merge
2025-10-03 06:18:46 -07:00
Daniel Gonzalez 6d763be032 Fixed merge conflicts 2025-10-02 15:44:26 -04:00
Chris Troutner dc6e6d956c Merge branch 'master' into ct-unstable 2025-09-20 09:37:13 -07:00
Chris Troutner 612f2edf70 Merge pull request #15 from Permissionless-Software-Foundation/dh-user-data
feat(userdata): Added user data page functionality
2025-09-20 07:24:23 -07:00
Daniel Gonzalez 408dc0d418 feat(userdata): Added user data page functionality 2025-09-19 15:31:12 -04:00
Chris Troutner 9b6e080b02 Merge pull request #65 from Permissionless-Software-Foundation/dh-user-addrs
feat(profile): listing BCH and SLP addresses on user profile
2025-09-18 06:54:08 -07:00
Daniel Gonzalez 404f230cce feat(profile): listing BCH and SLP addresses on user profile 2025-09-17 18:19:54 -04:00
Chris Troutner a81eee2dda Merge pull request #64 from Permissionless-Software-Foundation/dh-user-data-style
fix(userdata): Fixed markdown text styling on user data page
2025-09-16 15:33:49 -07:00
Daniel Gonzalez 1ffba5c210 fix(userdata): Fixed markdown text styling on user data page 2025-09-16 16:18:59 -04:00
Chris Troutner d30e9d6bfd Merge pull request #63 from Permissionless-Software-Foundation/dh-blacklist
feat(nostr): Removed entries from authors on the npub blacklist
2025-09-10 05:36:52 -07:00
Daniel Gonzalez ed56fe9e38 feat(nostr): Removed entries from authors on the npub blacklist 2025-09-09 18:27:27 -04:00
Chris Troutner fa623aa4eb Merge pull request #62 from Permissionless-Software-Foundation/dh-profile-dm
feat(nostr): Added functionality to message button in profile
2025-09-09 07:13:06 -07:00
Daniel Gonzalez 96f874f93d feat(nostr): Added functionality to message button in profile 2025-09-08 13:07:48 -04:00
Chris Troutner a1d31253c6 Merge pull request #61 from Permissionless-Software-Foundation/dh-private-dm
feat(nostr): Added NIP04 private messaging
2025-09-05 07:17:04 -07:00
Daniel Gonzalez 680f0c8662 feat(nostr): Added NIP04 private messaging 2025-09-04 21:18:53 -04:00
Chris Troutner 74fbe78cb7 Merge pull request #60 from Permissionless-Software-Foundation/dh-in-chat-menu
feat(nostr): In Chat: clicking on name should open menu
2025-09-02 11:24:39 -07:00
Daniel Gonzalez a38370962a feat(nostr): In Chat: clicking on name should open menu 2025-09-02 13:04:26 -04:00
Chris Troutner 88bf7e5f9a Merge pull request #59 from Permissionless-Software-Foundation/dh-channel-pfp
feat(nostr): Added channel PFPs
2025-08-31 06:28:24 -07:00
Daniel Gonzalez afa208d0d5 Merge branch 'master' of github.com:Permissionless-Software-Foundation/bch-dex-taker-v2 into dh-channel-pfp 2025-08-30 15:59:55 -04:00
Daniel Gonzalez 36c709f580 feat(nostr): Added channel PFPs 2025-08-30 15:02:10 -04:00
Chris Troutner d459e034cc Merge pull request #58 from Permissionless-Software-Foundation/dh-chat-markdown
feat(nostr): Added markdown support to Nostr Chat
2025-08-30 05:18:25 -07:00
Daniel Gonzalez 18584ba2ff feat(nostr): Added markdown support to Nostr Chat 2025-08-29 11:32:58 -04:00
Chris Troutner 88581290c1 Merge pull request #57 from Permissionless-Software-Foundation/ct-unstable
Minor code readability edits
2025-08-28 08:34:53 -07:00
Chris Troutner f640ec0921 Updating package.json 2025-08-28 08:33:45 -07:00
Chris Troutner d244f45897 More minor readability edits 2025-08-28 08:32:44 -07:00
Chris Troutner af27967415 minor code readability edits 2025-08-28 08:13:44 -07:00
Chris Troutner 9055cbcc5d Merge pull request #56 from Permissionless-Software-Foundation/dh-chat-UI
feat(nostr): Created nostr general chat interface
2025-08-28 06:45:57 -07:00
Daniel Gonzalez 574581b9f1 removed testing mock data file 2025-08-27 17:57:47 -04:00
Daniel Gonzalez 1ff9152718 feat(nostr): Created nostr general chat interface 2025-08-27 17:49:16 -04:00
Chris Troutner 8607ce4f15 Merge pull request #55 from Permissionless-Software-Foundation/dh-relay-selection
feat(nostr): Added relay selection to Configuration page
2025-08-23 11:48:02 -07:00
Daniel Gonzalez e18addff30 feat(nostr): Added relay selection to Configuration page 2025-08-23 14:24:24 -04:00
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
Chris Troutner 360be9f875 Adding LLM file 2025-06-19 17:22:28 -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
87 changed files with 31626 additions and 4981 deletions
+5
View File
@@ -0,0 +1,5 @@
# Development environment variables
# These are automatically loaded when running 'npm start'
REACT_APP_DEX_SERVER=http://localhost:5700
#REACT_APP_NOSTR_REST_API_URL=http://localhost:5942
+5
View File
@@ -0,0 +1,5 @@
# Production environment variables
# These are automatically loaded when running 'npm run build'
REACT_APP_DEX_SERVER=https://dex-api.fullstack.cash
REACT_APP_NOSTR_REST_API_URL=https://nostr-relay-api.psfoundation.info
+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
File diff suppressed because it is too large Load Diff
+11498 -4766
View File
File diff suppressed because it is too large Load Diff
+49 -7
View File
@@ -7,26 +7,41 @@
"@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.3.1",
"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"
"pub:ghp": "./deploy/publish-gh-pages.sh",
"postinstall": "rm -rf node_modules/fork-ts-checker-webpack-plugin/node_modules 2>/dev/null || true && (cd node_modules/@eslint/eslintrc && npm install ajv@^6.12.6 --no-save 2>/dev/null || true) && (cd node_modules/eslint && npm install ajv@^6.12.6 --no-save 2>/dev/null || true)"
},
"eslintConfig": {
"extends": "react-app"
@@ -44,8 +59,35 @@
]
},
"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"
},
"overrides": {
"ajv": "^8.17.1",
"ajv-keywords": "^5.1.0",
"ajv-formats": "^2.1.1",
"babel-loader": {
"schema-utils": "^2.7.1",
"ajv-keywords": "^3.5.2",
"ajv": "^6.12.6"
}
},
"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
+195
View File
@@ -31,3 +31,198 @@
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;
}
/* Markdown Content Styles */
.markdown-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
text-align: left;
}
.markdown-content h1 {
font-size: 2.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 3px solid #3b82f6;
text-align: left;
}
.markdown-content h2 {
font-size: 2rem;
font-weight: 600;
color: #374151;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
border-bottom: 2px solid #e5e7eb;
}
.markdown-content h3 {
font-size: 1.5rem;
font-weight: 600;
color: #4b5563;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
font-size: 1.25rem;
font-weight: 600;
color: #6b7280;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.markdown-content p {
font-size: 1.1rem;
line-height: 1.7;
color: #374151;
margin-bottom: 1.25rem;
text-align: left;
}
.markdown-content ul, .markdown-content ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.markdown-content li {
font-size: 1.1rem;
line-height: 1.6;
color: #374151;
margin-bottom: 0.5rem;
}
.markdown-content blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
border-radius: 0 8px 8px 0;
font-style: italic;
color: #4b5563;
}
.markdown-content code {
background: #f1f5f9;
color: #e11d48;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
}
.markdown-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1.5rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid #334155;
}
.markdown-content pre code {
background: transparent;
color: inherit;
padding: 0;
border-radius: 0;
}
.markdown-content a {
color: #3b82f6;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.markdown-content a:hover {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.markdown-content th, .markdown-content td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.markdown-content th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.markdown-content hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
margin: 2rem 0;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin: 1rem 0;
}
@media (max-width: 768px) {
.markdown-content {
padding: 1.5rem;
margin: 1rem;
}
.markdown-content h1 {
font-size: 2rem;
}
.markdown-content h2 {
font-size: 1.75rem;
}
.markdown-content p, .markdown-content li {
font-size: 1rem;
}
}
+102 -11
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,86 @@ 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])
/**
* Run background process to get bch and slp balance.
* Also update the background state process.
* On error this function should trigger a info modal notifying the errors.
*/
const backgroundAsync = useCallback(async (asyncLoad, walletTemp) => {
try {
appData.setModalBody(['Getting BCH balance in background!.'])
// Get Wallet Balance
await asyncLoad.getWalletBchBalance(walletTemp, appData.updateBchWalletState, appData)
// Update Background state
appData.updateBackGroundInitState({ bchInitLoaded: true })
// Get SLP Balance
appData.setModalBody(['Getting SLP tokens in background!.'])
await asyncLoad.getSlpTokenBalances(walletTemp, appData.updateBchWalletState, appData)
// Update Background state
appData.updateBackGroundInitState({ slpInitLoaded: true, asyncBackgroundFinished: true })
} catch (err) {
console.log('App.js backgroundAsync() error!', err)
appData.updateBackGroundInitState({ asyncBackgroundFinished: true })
addToModal(`Error: ${err.message}`, appData)
addToModal('Try selecting a different back end server using the drop-down menu at the bottom of the app.\'', appData)
// Update Modal State
appData.setHideSpinner(true)
appData.setShowStartModal(true)
appData.setDenyClose(false)
// Update the startup state.
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(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)
await appData.nostrQueries.start()
if (!appData.asyncInitStarted && !singleView) {
try {
// Instantiate the async load object.
const asyncLoad = new AsyncLoad()
@@ -59,18 +137,24 @@ function App (props) {
appData.setWallet(walletTemp)
// appData.updateBchWalletState({ walletObj: walletTemp.walletInfo, appData })
// Get the BCH balance of the wallet.
addToModal('Getting BCH balance', appData)
await asyncLoad.getWalletBchBalance(walletTemp, appData.updateBchWalletState, appData)
// Get the SLP tokens held by the wallet.
addToModal('Getting SLP tokens', appData)
await asyncLoad.getSlpTokenBalances(walletTemp, appData.updateBchWalletState, appData)
// Get the BCH spot price
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)
@@ -79,6 +163,13 @@ function App (props) {
appData.setAsyncInitFinished(true)
appData.setAsyncInitSucceeded(true)
console.log('App.js useEffect() startup finished successfully')
backgroundAsync(asyncLoad, walletTemp)
// Get the BCH balance of the wallet.
// addToModal('Getting BCH balance', appData)
// Get the SLP tokens held by the wallet.
// addToModal('Getting SLP tokens', appData)
} catch (err) {
const errModalBody = [
`Error: ${err.message}`,
@@ -98,7 +189,7 @@ function App (props) {
}
}
asyncEffect()
}, [appData, addToModal])
}, [appData, addToModal, isSignleView, backgroundAsync])
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,86 @@
/*
This card displays the users balance in BCH.
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Row, Col, Card, Spinner } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCoins } from '@fortawesome/free-solid-svg-icons'
const BalanceCard = (props) => {
const { appData } = props
const [sats, setSats] = useState('')
const [bchBalance, setbchBalance] = useState('')
const [usdBalance, setusdBalance] = useState('')
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// Calculate balances if wallet is successfully loaded!
useEffect(() => {
try {
const bchjs = appData.wallet.bchjs
if (bchjs && appData.asyncInitSucceeded) {
const sats = appData.bchWalletState.bchBalance
const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats)
const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice)
setSats(sats)
setbchBalance(bchBalance)
setusdBalance(usdBalance)
}
} catch (error) {
// console.warn(error)
}
}, [appData])
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
const backgroundDataError = !bchInitLoaded && asyncBackgroundFinished
return (
<>
<Card>
<Card.Body>
<Card.Title style={{ textAlign: 'center' }}>
<h2><FontAwesomeIcon icon={faCoins} size='lg' /> Balance</h2>
</Card.Title>
<br />
{bchInitLoaded && (
<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>)}
{backgroundDataError && (
<Container>
<span style={{ color: 'red' }}>Balance could not be loaded!</span>
</Container>
)}
{!backgroundDataLoaded && appData.asyncInitSucceeded && (
<div className='balance-spinner-container'>
<Spinner animation='border' />
</div>
)}
</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,93 @@
/*
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 })
// if bchInitLoaded is 'false', them set as true , to show the new balance.
appData.updateBackGroundInitState({ bchInitLoaded: true })
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,44 @@
/*
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
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
// 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) }} disabled={!backgroundDataLoaded}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
<RefreshBchBalance appData={appData} ref={refreshBchBalanceRef} />
</>
)
}
export default RefreshBchBalanceButton
@@ -0,0 +1,336 @@
/*
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
const { bchInitLoaded, asyncBackgroundFinished } = appData.asyncBackGroundInitState
// 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)
// Background bch data loaded finished
const backgroundDataLoaded = bchInitLoaded || asyncBackgroundFinished
// 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 })} disabled={!backgroundDataLoaded}>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,22 @@
/*
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'
import RelaySelectionView from './relay-selection-view'
function ConfigurationView (props) {
const { appData } = props
return (
<>
<ServerSelectView appData={appData} />
<RelaySelectionView appData={appData} />
</>
)
}
export default ConfigurationView
@@ -0,0 +1,333 @@
/*
This component is a View that allows the user to manage Nostr relay settings.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Row, Col, Form, Card, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faShareAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
function RelaySelectionView (props) {
const { appData } = props
const { relaysData, updateRelaysData, restoreRelaysData } = appData
console.log('relaysData', relaysData)
const [newRelayAddress, setNewRelayAddress] = useState('')
const addRelay = (newRelay) => {
const exist = relaysData.find(relay => relay.address === newRelay)
if (!exist) {
relaysData.push({ address: newRelay, read: true, write: true })
updateRelaysData(relaysData)
}
}
// Delete relay from relaysData array
const deleteRelay = (relayToDelete) => {
const index = relaysData.findIndex(relay => relay.address === relayToDelete.address)
if (index !== -1) {
relaysData.splice(index, 1)
updateRelaysData(relaysData)
}
}
const handleReadToggle = (relayToToggleRead) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleRead.address)
if (index !== -1) {
relaysData[index].read = !relaysData[index].read
updateRelaysData(relaysData)
}
}
const handleWriteToggle = (relayToToggleWrite) => {
const index = relaysData.findIndex(relay => relay.address === relayToToggleWrite.address)
if (index !== -1) {
relaysData[index].write = !relaysData[index].write
updateRelaysData(relaysData)
}
}
return (
<>
<Card className='m-3'>
<Card.Body>
<Row>
<Col style={{ textAlign: 'center' }}>
<h2>Nostr Relay Configuration</h2>
<p>
Manage your Nostr relay connections. Configure read and write permissions for each relay.
</p>
</Col>
</Row>
<hr />
<Row className='mb-3'>
<Col className='text-end'>
<Button
onClick={() => restoreRelaysData()}
variant='outline-secondary'
style={{ minWidth: '120px' }}
>
Restore defaults
</Button>
</Col>
</Row>
<Row>
<Col>
<div
className='relay-table-container' style={{
backgroundColor: 'transparent',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='relay-table-header' style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
paddingBottom: '15px',
borderBottom: '2px solid #e9ecef'
}}
>
<h5 style={{ margin: 0, color: '#495057', fontWeight: '600' }}>Relay Connections</h5>
<span style={{
fontSize: '0.85em',
color: '#6c757d',
backgroundColor: '#e9ecef',
padding: '4px 12px',
borderRadius: '20px'
}}
>
{relaysData.length} relays
</span>
</div>
<div className='relay-list'>
{relaysData.map((relay, index) => (
<div
key={`relay-${index}`} style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '12px',
border: '1px solid #e9ecef',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}}
>
{/* Relay Address */}
<div style={{ flex: '1', minWidth: '200px' }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.9em',
color: '#495057',
fontWeight: '500',
wordBreak: 'break-all'
}}
>
{relay.address}
</div>
</div>
{/* Controls */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}
>
{/* Read/Write Toggles */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
backgroundColor: '#f8f9fa',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #dee2e6'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Read
</span>
<Form.Check
type='switch'
onClick={() => handleReadToggle(relay)}
checked={relay.read}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.8em',
color: '#6c757d',
fontWeight: '500',
minWidth: '35px'
}}
>
Write
</span>
<Form.Check
type='switch'
onClick={() => handleWriteToggle(relay)}
checked={relay.write}
className='mb-0'
style={{ margin: 0 }}
/>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<Button
variant='outline-primary'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faShareAlt} size='xs' />
Share
</Button>
<Button
onClick={() => deleteRelay(relay)}
variant='outline-danger'
size='sm'
className='d-flex align-items-center gap-1'
style={{
minWidth: '65px',
fontSize: '0.8em',
padding: '4px 8px',
borderWidth: '1px'
}}
>
<FontAwesomeIcon icon={faTrashAlt} size='xs' />
Delete
</Button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</Col>
</Row>
<Row className='mt-4'>
<Col>
<div
className='add-relay-container' style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
border: '1px solid #e9ecef',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<div
className='add-relay-header' style={{
marginBottom: '16px',
paddingBottom: '12px',
borderBottom: '2px solid #e9ecef'
}}
>
<h6 style={{
margin: 0,
color: '#495057',
fontWeight: '600',
fontSize: '1rem'
}}
>
Add New Relay
</h6>
<p style={{
margin: '4px 0 0 0',
fontSize: '0.85em',
color: '#6c757d'
}}
>
Enter the URL of a Nostr relay to add it to your configuration
</p>
</div>
<div
className='add-relay-form' style={{
display: 'flex',
gap: '12px',
alignItems: 'end'
}}
>
<div style={{ flex: '1' }}>
<Form.Label
htmlFor='newRelayAddress'
style={{
fontSize: '0.9em',
fontWeight: '500',
color: '#495057',
marginBottom: '8px'
}}
>
Relay Address
</Form.Label>
<Form.Control
type='text'
id='newRelayAddress'
placeholder='wss://your-relay-address.com'
value={newRelayAddress}
onChange={(e) => setNewRelayAddress(e.target.value)}
style={{
fontFamily: 'monospace',
fontSize: '0.9em',
border: '1px solid #dee2e6',
borderRadius: '6px',
padding: '10px 12px'
}}
/>
</div>
<Button
onClick={() => addRelay(newRelayAddress)}
variant='primary'
style={{
minWidth: '80px',
height: '38px',
borderRadius: '6px',
fontSize: '0.9em',
fontWeight: '500',
padding: '8px 16px'
}}
>
Add
</Button>
</div>
</div>
</Col>
</Row>
</Card.Body>
</Card>
</>
)
}
export default RelaySelectionView
@@ -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
@@ -0,0 +1,184 @@
/*
This component renders as a button. When clicked, it opens a modal that
cancels the counter offer.
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, Spinner } from 'react-bootstrap'
import Sweeper from 'bch-token-sweep'
function CancelCounterOfferBtn (props) {
const [show, setShow] = useState(false)
const [statusMsg, setStatusMsg] = useState('')
const [hideSpinner, setHideSpinner] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(true) // show the confirmation view
// Update wallet state function
const updateWalletState = async () => {
const wallet = props.appData.wallet
const bchBalance = await wallet.getBalance({ bchAddress: wallet.walletInfo.cashAddress })
await wallet.initialize()
const slpTokens = await wallet.listTokens(wallet.walletInfo.cashAddress)
props.appData.updateBchWalletState({ walletObj: { bchBalance, slpTokens }, appData: props.appData })
}
// Handle cancel/sweep function
const handleCancel = async () => {
try {
console.log('Canceling Counter Offer')
// Hide confirmation and show processing
setShowConfirmation(false)
setHideSpinner(false)
setStatusMsg('')
setIsProcessing(true)
// Get the keypair that holds Counter Offer UTXOs.
// This uses the same derivation path as used in the counter offers page (index 1)
const bchDexLib = props.appData.dexLib
const keyPair = await bchDexLib.take.util.getKeyPair(1)
const wif = keyPair.wif
// Get wallet info for sweeping
const walletWif = props.appData.wallet.walletInfo.privateKey
const toAddr = props.appData.wallet.slpAddress
// Instance the Sweep library and populate UTXOs from network
const sweep = new Sweeper(wif, walletWif, props.appData.wallet)
await sweep.populateObjectFromNetwork()
// Constructing the sweep transaction
const hex = await sweep.sweepTo(toAddr)
const txid = await props.appData.wallet.ar.sendTx(hex)
// Generate success status message
const newStatusMsg = (
<>
<p>Sweep succeeded!</p>
<p>Counter Offer has been canceled.</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)
setIsProcessing(false)
// Update wallet state to reflect the changes
await updateWalletState()
// Refresh the counter offers list if refreshTokens function is provided
if (props.refreshTokens) {
// Small delay to ensure blockchain state is updated
setTimeout(() => {
props.refreshTokens()
}, 2000)
}
} catch (err) {
console.error('Error in handleCancel(): ', err)
setHideSpinner(true)
setIsProcessing(false)
setStatusMsg(<b style={{ color: 'red' }}>{`Error: ${err.message}`}</b>)
}
}
const handleClose = () => {
// Deny close if the cancel is in progress.
if (isProcessing) {
return
}
setShow(false)
setStatusMsg('')
setHideSpinner(false)
setIsProcessing(false)
setShowConfirmation(true) // Reset confirmation state for next time
}
const handleOpen = () => {
setShow(true)
// Don't start the cancel process immediately - wait for user confirmation
}
return (
<>
<Button variant='danger' onClick={handleOpen} disabled={isProcessing}>Cancel</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Cancel Counter Offer</Modal.Title>
</Modal.Header>
<Modal.Body>
{showConfirmation
? (
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<p>Are you sure you want to cancel this Counter Offer?</p>
<p style={{ color: '#666', fontSize: '0.9em' }}>
This action will sweep the Counter Offer UTXOs back to your wallet.
</p>
</Col>
</Row>
</Container>
)
: (
<Container>
<Row>
{!hideSpinner && (
<Col style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ marginRight: '10px' }}>Canceling Counter Offer...</span>
<Spinner animation='border' />
</Col>
)}
</Row>
<br />
{statusMsg && (
<Row>
<Col style={{ textAlign: 'center' }}>{statusMsg}</Col>
</Row>
)}
</Container>
)}
</Modal.Body>
<Modal.Footer>
{showConfirmation && (
<Row style={{ width: '100%' }}>
<Col xs={12} className='text-center'>
<Button
variant='secondary'
style={{ minWidth: '100px', marginRight: '10px' }}
onClick={handleClose}
>
No, Keep Open
</Button>
<Button
variant='danger'
style={{ minWidth: '100px' }}
onClick={handleCancel}
>
Yes, Cancel It
</Button>
</Col>
</Row>
)}
</Modal.Footer>
</Modal>
</>
)
}
export default CancelCounterOfferBtn
@@ -0,0 +1,90 @@
/*
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, Button } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import CancelCounterOfferBtn from './cancel-counter-offer-btn'
function CounterOfferCard (props) {
const { token, appData, refreshTokens } = 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 />
<br />
<Row className='text-center'>
<Col xs={4}>
<InfoButton token={props.token} />
</Col>
<Col xs={4}>
<Button
href={`/profile/${props.token.makerNpub}#single-view`}
target='_blank'
rel='noopener noreferrer'
disabled={!props.token.makerNpub}
>
Seller
</Button>
</Col>
<Col xs={4}>
<CancelCounterOfferBtn
token={props.token}
appData={appData}
refreshTokens={refreshTokens}
/>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default CounterOfferCard
@@ -0,0 +1,246 @@
/*
This component displays the counter offers for the current wallet.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import CounterOfferCard from './counter-offer-card'
import AsyncLoad from '../../../services/async-load'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// Local libraries
const CounterOffers = (props) => {
const { appData } = props
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [counterOffers, setCounterOffers] = useState([])
const [isLoading, setIsLoading] = useState(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
}
// 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
thisToken.tokenId = tokenData.genesisData.tokenId
thisToken.ticker = tokenData.genesisData.ticker
thisToken.name = tokenData.genesisData.name
thisToken.decimals = tokenData.genesisData.decimals
thisToken.tokenType = tokenData.genesisData.type
thisToken.url = tokenData.genesisData.documentUri
}
// 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.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
// Load the counter offers for the current wallet.
const loadData = useCallback(async () => {
try {
setIsLoading(true)
setCounterOffers([])
// Get the wallet state and server url from the app data.
const { bchWalletState, serverUrl } = appData
// Create a new AsyncLoad instance.
const asyncLoad = new AsyncLoad()
// Load the wallet library.
await asyncLoad.loadWalletLib()
// Get the counter offer derivated wallet
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1")
// Get the utxos from the counter offer wallet.
const utxos = await counterOfferWallet.getUtxos()
const bchUtxos = utxos.bchUtxos
// Get the counter offers data for the current wallet address.
const { counterOffers } = await asyncLoad.getCounterOffersByAddress(bchWalletState.cashAddress)
// Filter the counter offers to only include the ones that are in the utxos.
const filteredCounterOffers = counterOffers.filter(val =>
bchUtxos.some(utxo => utxo.txid === val.counterOfferUtxo)
)
setCounterOffers(filteredCounterOffers)
setIsLoading(false)
// Load the token data for the counter offers in background.
await lazyLoadTokenData(filteredCounterOffers)
// Load the token icons for the counter offers in background.
await lazyLoadMutableData(filteredCounterOffers)
} catch (error) {
setIsLoading(false)
console.error('Error loading counter offers:', error)
}
}, [appData, lazyLoadTokenData, lazyLoadMutableData])
// Start to load the token icons when the component is mounted
useEffect(() => {
loadData()
}, [loadData])
// Handler for refresh button
const handleRefresh = useCallback(() => {
loadData()
}, [loadData])
// Generate the token cards for each token in the wallet.
const generateCards = () => {
return counterOffers.map(thisCounterOffer => (
<CounterOfferCard
appData={appData}
token={thisCounterOffer}
key={`${thisCounterOffer.id}`}
refreshTokens={loadData}
/>
))
}
return (
<>
<Container>
<Row>
<Col xs={6} style={{ textAlign: 'start' }}>
{!isLoading && (
<Row>
<Col xs={6}>
<Button variant='success' onClick={handleRefresh}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
</Col>
</Row>
)}
</Col>
</Row>
<Row>
{appData.asyncInitSucceeded && (
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
isLoading && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Counter Offers </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but data is not loaded */
!isLoading && !dataAreLoaded && counterOffers.length > 0 && (
<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 */
!isLoading && 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>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{!isLoading && counterOffers.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
</Container>
</>
)
}
export default CounterOffers
@@ -0,0 +1,146 @@
/*
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, useEffect } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } 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 [mutableDataCid, setMutableDataCid] = useState(null)
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)
// 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 {
console.log('props token', props.token)
const userDataStr = props.token.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])
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>
{!props.token.iconAlreadyDownloaded && (
<div className='text-center'>
<Spinner animation='border' size='sm' />
</div>
)}
{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'
variant='success'
>
View User Data
</Button>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
</>
)
}
export default InfoButton
+240
View File
@@ -0,0 +1,240 @@
/*
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'
import AsyncLoad from '../../../services/async-load'
// 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 Fungible (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, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
// 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 Fungible
+32 -5
View File
@@ -15,9 +15,22 @@ 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 './configuration/select-server-view.js'
// 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 ConfigurationView from './configuration/index.js'
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 Fungible from './fungible'
import NostrChat from './nostr-chat'
import CounterOffers from './counter-offers'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -25,15 +38,29 @@ 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={<ConfigurationView 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='/fungible' element={<Fungible appData={appData} />} />
<Route path='/counter-offers' element={<CounterOffers appData={appData} />} />
<Route path='/nostr-chat' element={<NostrChat 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,230 @@
/*
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'
import AsyncLoad from '../../../services/async-load'
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, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOffer
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
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,41 @@
import React from 'react'
import { Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
function FilterDropdown (props) {
const { selectedFilter, setSelectedFilter } = props
return (
<Dropdown>
<Dropdown.Toggle
variant='outline-secondary'
className='d-flex align-items-center justify-content-around gap-2 mt-3'
style={{ minWidth: '150px' }}
>
<FontAwesomeIcon icon={faFilter} />
<span>{selectedFilter}</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setSelectedFilter('Misc')}>
Misc
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Art')}>
Art
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Video')}>
Video
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Writing')}>
Writing
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelectedFilter('Download')}>
Download
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)
}
export default FilterDropdown
@@ -0,0 +1,532 @@
/*
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, useRef } 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'
import FilterDropdown from './filter-dropdown'
// 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)
const [currentPage, setCurrentPage] = useState(0)
const [lastLoadedPage, setLastLoadedPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [selectedFilter, setSelectedFilter] = useState('Misc')
// Flag to prevent load data multiple times on component mount!
const componentMountRef = useRef(false)
// Handler for previous page
const handlePreviousPage = () => {
if (currentPage > 0) {
setCurrentPage(currentPage - 1)
}
}
// Handler for next page
const handleNextPage = () => {
if (currentPage < totalPages - 1) {
setCurrentPage(currentPage + 1)
}
}
// 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])
// Function to process token metadata (iconUrl , userData).
const processOfferMetadata = useCallback(async (offer) => {
try {
// Token icon
if (offer.tokenIconUrl) {
offer.icon = offer.tokenIconUrl
offer.iconAlreadyDownloaded = true
offer.userData = JSON.parse(offer.userDataStr)
}
return offer
} catch (error) {
return offer
}
}, [])
// 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.data
console.log('rawOffers: ', rawOffers)
setTotalPages(result.data.pagination.totalPages)
// Process each offer
const processedOffers = []
for (let i = 0; i < rawOffers.length; i++) {
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
const processedOfferMetadata = await processOfferMetadata(processedOffer)
processedOffers.push(processedOfferMetadata)
}
setOffersAreLoaded(true)
return processedOffers
} catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
}
}, [processTokenData, processOfferMetadata])
// 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
console.log('Icon already downloaded ', thisToken.iconAlreadyDownloaded)
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.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 (page = 0) => {
try {
setIsLoading(true)
// Get tokens in offers
const offers = await getNftOffers(page)
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(() => {
// Peevent to run multiple times
if (!componentMountRef.current) {
console.log('loading nfts for sale')
loadNftOffers()
componentMountRef.current = true
}
}, [loadNftOffers])
// Effect to reload data when page changes
useEffect(() => {
// Validate to prevent load same page again
if (currentPage >= 0 && currentPage !== lastLoadedPage) {
console.log('page changed, reloading nfts for sale')
loadNftOffers(currentPage)
setLastLoadedPage(currentPage)
}
}, [currentPage, loadNftOffers, lastLoadedPage])
// Handler for refresh button
const handleRefresh = useCallback(() => {
console.log('handling refresh')
loadNftOffers(currentPage)
}, [currentPage, 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
}
const updateOffer = useCallback(async (offer) => {
try {
if (!offer) return
setOffersAreLoaded(false)
const processedOffer = await processTokenData(offer)
const processedOfferMetadata = await processOfferMetadata(processedOffer)
setOffers(offers => {
// find offer
const index = offers.findIndex((val) => { return val.tokenId === offer.tokenId })
// Save existing token data
processedOfferMetadata.tokenData = offers[index].tokenData
// replace with the new data
offers[index] = processedOfferMetadata
return offers
})
// Re-render tokens cards , to load the new data.
setTimeout(() => {
setOffersAreLoaded(true)
}, 500)
} catch (error) {
console.warn(error)
}
}, [processOfferMetadata, processTokenData])
// This function generates a Token Card for each token in the wallet.
const generateCards = useCallback(() => {
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}`}
updateOffer={updateOffer}
/>
)
tokenCards.push(thisTokenCard)
}
return tokenCards
}, [offers, appData, handleRefresh, updateOffer])
return (
<Container>
<Row>
<Col>
<h1>NFTs for Sale</h1>
</Col>
<Col className='d-flex justify-content-end'>
<FilterDropdown selectedFilter={selectedFilter} setSelectedFilter={setSelectedFilter} />
</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>
)}
{/* Pagination Controls */}
{offersAreLoaded && totalPages > 1 && (
<div
style={{
position: 'fixed',
bottom: '70px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '25px',
padding: '12px 20px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
display: 'flex',
alignItems: 'center',
gap: '16px',
minWidth: '280px',
justifyContent: 'center'
}}
>
<Button
variant='outline-primary'
size='sm'
onClick={handlePreviousPage}
disabled={currentPage === 0}
style={{
borderRadius: '20px',
padding: '8px 16px',
border: '2px solid #007bff',
background: currentPage === 0 ? 'transparent' : '#007bff',
color: currentPage === 0 ? '#6c757d' : 'white',
fontWeight: '500',
fontSize: '14px',
transition: 'all 0.3s ease',
minWidth: '80px',
opacity: currentPage === 0 ? 0.5 : 1,
cursor: currentPage === 0 ? 'not-allowed' : 'pointer'
}}
onMouseEnter={(e) => {
if (currentPage > 0) {
e.target.style.background = '#0056b3'
e.target.style.transform = 'translateY(-1px)'
e.target.style.boxShadow = '0 4px 12px rgba(0, 123, 255, 0.3)'
}
}}
onMouseLeave={(e) => {
if (currentPage > 0) {
e.target.style.background = '#007bff'
e.target.style.transform = 'translateY(0)'
e.target.style.boxShadow = 'none'
}
}}
>
Previous
</Button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
color: '#495057'
}}
>
<span style={{ color: '#007bff', fontWeight: '600' }}>
{currentPage + 1}
</span>
<span style={{ color: '#6c757d' }}>of</span>
<span style={{ color: '#495057', fontWeight: '600' }}>
{totalPages}
</span>
</div>
<Button
variant='outline-primary'
size='sm'
onClick={handleNextPage}
disabled={currentPage >= totalPages - 1}
style={{
borderRadius: '20px',
padding: '8px 16px',
border: '2px solid #007bff',
background: currentPage >= totalPages - 1 ? 'transparent' : '#007bff',
color: currentPage >= totalPages - 1 ? '#6c757d' : 'white',
fontWeight: '500',
fontSize: '14px',
transition: 'all 0.3s ease',
minWidth: '80px',
opacity: currentPage >= totalPages - 1 ? 0.5 : 1,
cursor: currentPage >= totalPages - 1 ? 'not-allowed' : 'pointer'
}}
onMouseEnter={(e) => {
if (currentPage < totalPages - 1) {
e.target.style.background = '#0056b3'
e.target.style.transform = 'translateY(-1px)'
e.target.style.boxShadow = '0 4px 12px rgba(0, 123, 255, 0.3)'
}
}}
onMouseLeave={(e) => {
if (currentPage < totalPages - 1) {
e.target.style.background = '#007bff'
e.target.style.transform = 'translateY(0)'
e.target.style.boxShadow = 'none'
}
}}
>
Next
</Button>
</div>
)}
{!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,150 @@
/*
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, Spinner } from 'react-bootstrap'
import axios from 'axios'
// Local libraries
import config from '../../../config'
// Global variables and constants
const SERVER = config.dexServer
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const [loading, setLoading] = useState(false)
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.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])
// Update offer data
const updateOffer = async () => {
try {
setLoading(true)
const inputObj = { tokenId: props.token.tokenId }
const result = await axios.post(`${SERVER}/offer/mutable/sync/`, inputObj)
const offerData = result.data
console.log('offerData: ', offerData)
if (props.updateOffer) await props.updateOffer(offerData)
setLoading(false)
} catch (error) {
setLoading(false)
}
}
// 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'
variant='success'
>
View User Data
</Button>
</Col>
</Row>
)}
</Container>
</Modal.Body>
<Modal.Footer style={{ justifyContent: 'center' }}>
{!loading && <Button style={{ width: '135px' }} onClick={updateOffer}>Update</Button>}
{loading && <Spinner />}
</Modal.Footer>
</Modal>
</>
)
}
export default InfoButton
@@ -0,0 +1,186 @@
/*
This component displays the seller's profile information for an NFT listing.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
function SellerProfile (props) {
const { npub, appData } = props
const [profile, setProfile] = useState(null)
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const { nostrQueries } = appData
// Get profile if npub is provided.
useEffect(() => {
const start = async () => {
setIsLoading(true)
try {
const pubKey = nostrQueries.npubToHex(npub)
const profile = await nostrQueries.getProfile(pubKey)
setProfile(profile)
} catch (error) {
console.warn('Error fetching seller profile:', error)
} finally {
setIsLoading(false)
}
}
if (npub) {
start()
} else {
setIsLoading(false)
}
}, [npub, nostrQueries])
// handle img url errors
const handleImageError = (type) => {
setImageError(true)
}
// go to profile
const goToProfile = () => {
if (npub) {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
}
// Get display name - use profile name, npub short form, or "Anonymous User"
const getDisplayName = () => {
if (profile?.name) return profile.name
if (npub) {
return npub.slice(0, 8) + '...' + npub.slice(-6)
}
return 'Anonymous User'
}
return (
<div
onClick={goToProfile}
className='seller-profile-container'
style={{
padding: '4px 8px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
border: '1px solid #e9ecef',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
width: '100%'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8f9fa'
e.currentTarget.style.borderColor = '#e9ecef'
}}
>
<div className='d-flex align-items-center justify-content-center gap-1'>
{/* Seller Label */}
<small
className='text-muted fw-semibold'
style={{
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: '0.3px'
}}
>
Seller:
</small>
{/* Profile Picture or Placeholder */}
<div
className='d-flex align-items-center justify-content-center'
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: '#e9ecef',
border: '1.5px solid #dee2e6',
overflow: 'hidden',
flexShrink: 0,
cursor: 'pointer',
transition: 'transform 0.2s ease'
}}
onClick={(e) => {
e.stopPropagation()
goToProfile()
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
}}
>
{isLoading
? (
<FontAwesomeIcon
icon={faUser}
style={{
color: '#adb5bd',
fontSize: '12px',
opacity: 0.5
}}
/>
)
: (profile?.picture && !imageError)
? (
<img
src={profile.picture}
alt='Seller profile'
className='w-100 h-100'
style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')}
/>
)
: (
<FontAwesomeIcon
icon={faUser}
style={{
color: '#6c757d',
fontSize: '12px'
}}
/>
)}
</div>
{/* Seller Name */}
<span
onClick={(e) => {
e.stopPropagation()
goToProfile()
}}
className='cursor-pointer fw-medium'
style={{
color: '#495057',
fontSize: '0.8rem',
transition: 'color 0.2s ease',
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#007bff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#495057'
}}
title={profile?.name || npub}
>
{getDisplayName()}
</span>
</div>
</div>
)
}
export default SellerProfile
@@ -0,0 +1,91 @@
/*
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'
import SellerProfile from './seller-profile'
function TokenCard (props) {
const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
// Update icon state every token.icon and token.tokenData changes
useEffect(() => {
console.log('setting icon')
setIcon(token.icon)
setTokenData(token.tokenData)
}, [token.icon, token.tokenData])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card className='shadow-sm'>
<Card.Body style={{ textAlign: 'center', padding: '10px' }}>
{/* Seller Profile Section */}
<Row className='mb-2'>
<Col className='text-center mb-2'>
<SellerProfile npub={token.makerNpub} appData={appData} />
</Col>
</Row>
{/** 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} {...props} />
</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,105 @@
/*
Component for displaying channel item
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { Spinner } from 'react-bootstrap'
export default function ChannelItem (props) {
const { channel, selectedChannel, onChangeChannel, channelsData } = props
const [chInfo, setChInfo] = useState()
const getShortName = useCallback((str) => {
return str.slice(0, 8) + '...' + str.slice(-5)
}, [])
useEffect(() => {
if (!chInfo && channelsData[channel]) {
setChInfo(channelsData[channel])
}
}, [chInfo, channelsData, channel])
const handleClick = () => {
if (onChangeChannel) {
onChangeChannel(channel)
}
}
return (
<div
className={`d-flex align-items-center p-2 rounded-3 mb-2 cursor-pointer ${selectedChannel === channel ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: selectedChannel === channel ? '#0d6efd' : 'transparent',
border: '1px solid transparent'
}}
onClick={handleClick}
onMouseEnter={(e) => {
if (selectedChannel !== channel) {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}
}}
onMouseLeave={(e) => {
if (selectedChannel !== channel) {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}
}}
>
{/* Channel Avatar/Icon */}
<div className='me-3 flex-shrink-0 d-flex align-items-center'>
{chInfo?.picture
? (
<img
src={chInfo.picture}
alt={chInfo.name || 'Channel'}
className='rounded-circle'
style={{ width: '32px', height: '32px' }}
/>
)
: (
<div
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '32px',
height: '32px',
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '14px'
}}
>
#
</div>
)}
</div>
{/* Channel Info */}
<div className='flex-grow-1 min-w-0'>
<div
className={`${selectedChannel === channel ? 'text-white' : 'text-dark'} fw-medium`}
style={{ fontSize: '14px' }}
>
{chInfo?.name || getShortName(channel)}
</div>
{!chInfo?.name && (
<div
className={`small ${selectedChannel === channel ? 'text-white-50' : 'text-muted'}`}
style={{ fontSize: '12px' }}
>
Loading channel...
</div>
)}
</div>
{/* Loading indicator */}
{!chInfo?.name && (
<div className='ms-2'>
<Spinner animation='border' size='sm' />
</div>
)}
</div>
)
}
@@ -0,0 +1,29 @@
/*
Component for displaying the list of available channels
*/
// Global npm libraries
import React from 'react'
import { ListGroup } from 'react-bootstrap'
import ChannelItem from './channel-item'
function ChannelList (props) {
const { groupChannels, selectedChannel } = props
if (!groupChannels || groupChannels.length === 0) {
return (
<div className='text-muted small' style={{ fontStyle: 'italic' }}>
No channels available
</div>
)
}
return (
<ListGroup variant='flush' className='bg-transparent'>
{groupChannels.map((channel, i) => (
<ChannelItem key={`channel${i}`} channel={channel} selectedChannel={selectedChannel} {...props} />
))}
</ListGroup>
)
}
export default ChannelList
@@ -0,0 +1,53 @@
/*
Component for the chat header displaying chat info
*/
// Global npm libraries
import React, { useState, useCallback, useEffect } from 'react'
import { Spinner } from 'react-bootstrap'
function ChatHeader (props) {
const { selectedChannel, channelsData, selectedChannelIsDm, profiles } = props
const [chInfo, setChInfo] = useState() // channel data
const getShortName = useCallback((str) => {
if (str.length < 20) return str
return str.slice(0, 4) + '...' + str.slice(-4)
}, [])
// Restore ch info
useEffect(() => {
setChInfo(null)
}, [selectedChannel])
// get channel info
useEffect(() => {
// Public channel
if (!chInfo && !selectedChannelIsDm && channelsData[selectedChannel]) {
setChInfo(channelsData[selectedChannel])
}
// Dm Channel
if (!chInfo && selectedChannelIsDm && profiles[selectedChannel]) {
setChInfo(profiles[selectedChannel])
}
}, [chInfo, channelsData, selectedChannel, profiles, selectedChannelIsDm])
return (
<div className='p-3 d-flex justify-content-between align-items-center' style={{ backgroundColor: '#ffffff' }}>
{chInfo &&
<div className='d-flex justify-content-center align-items-center'>
{chInfo.picture && <img src={chInfo.picture} alt={chInfo.name} style={{ width: '50px', marginRight: '10px', borderRadius: '50%' }} />}
<div className='flex-grow-1'>
<h5 className='mb-1 text-dark'>{getShortName(chInfo?.name || selectedChannel)}</h5>
<small className='text-muted'>{chInfo?.about || ''}</small>
</div>
</div>}
{!chInfo &&
<div className='flex-grow-1 ps-4'>
<Spinner animation='border' size='sm' />
</div>}
</div>
)
}
export default ChatHeader
@@ -0,0 +1,49 @@
/*
Component for the main chat area
*/
// Global npm libraries
import React from 'react'
// Local libraries
import ChatHeader from './chat-header'
import MessageList from './message-list'
import MessageInput from './message-input'
import { Spinner } from 'react-bootstrap'
function ChatMain (props) {
const { selectedChannel, messages } = props
return (
<>
{!selectedChannel && (
<div className='h-100 d-flex align-items-center justify-content-center' style={{ backgroundColor: '#f8f9fa', minHeight: '200px' }}>
<div className='text-center text-muted'>
<Spinner animation='border' size='sm' />
</div>
</div>
)}
{selectedChannel && (
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#f8f9fa' }}>
{/* Chat Header */}
<div>
<ChatHeader selectedChannel={selectedChannel} {...props} />
</div>
{/* Messages Area */}
<div className='flex-grow-1 overflow-auto'>
<MessageList messages={messages} {...props} />
</div>
{/* Message Input */}
<div className='border-top border-tertiary'>
<MessageInput {...props} />
</div>
</div>
)}
</>
)
}
export default ChatMain
@@ -0,0 +1,48 @@
/*
Component for the chat sidebar channels
*/
// Global npm libraries
import React from 'react'
// Local libraries
import ChannelList from './channel-list'
import DMList from './dm-list'
function ChatSidebar (props) {
const { dmChannels, profiles } = props
return (
<div className='h-100 d-flex flex-column' style={{ backgroundColor: '#ffffff', borderRight: '1px solid #e9ecef' }}>
{/* Channels Section */}
<div className='flex-grow-1 d-flex flex-column'>
<div className='p-4 flex-grow-1'>
<div className='d-flex justify-content-between align-items-center mb-3'>
<h5 className='mb-0 text-dark fw-semibold' style={{ fontSize: '16px', letterSpacing: '0.5px' }}>
Channels
</h5>
</div>
<ChannelList
{...props}
/>
{/* DMs Section */}
<div className='mt-4 pt-4 border-top'>
<div className='d-flex justify-content-between align-items-center mb-3'>
<h5 className='mb-0 text-dark fw-semibold' style={{ fontSize: '16px', letterSpacing: '0.5px' }}>
DMs
</h5>
</div>
<DMList
dms={dmChannels}
profiles={profiles}
{...props}
/>
</div>
</div>
</div>
</div>
)
}
export default ChatSidebar
@@ -0,0 +1,49 @@
/*
Component for displaying date separators between message groups.
*/
// Global npm libraries
import React from 'react'
function DateSeparator (props) {
const { date } = props
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Today'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday'
} else {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}
return (
<div className='text-center my-4'>
<div
className='d-inline-block px-3 py-1 rounded-pill'
style={{
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '12px',
fontWeight: '500'
}}
>
{formatDate(date)}
</div>
</div>
)
}
export default DateSeparator
@@ -0,0 +1,129 @@
/*
Component for displaying direct message item
*/
// Global npm libraries
import React, { useCallback, useEffect, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { Spinner } from 'react-bootstrap'
export default function DMItem (props) {
const { dm, profiles, selectedChannel, onChangeChannel } = props
const { pubKey } = dm
const [profile, setProfile] = useState(profiles[pubKey])
const isSelected = selectedChannel === pubKey
useEffect(() => {
const profile = profiles[pubKey]
if (profile) {
setProfile(profile)
}
}, [profiles, pubKey])
const getShortName = useCallback((str) => {
if (!str || !str.startsWith('npub')) return str
const prefix = 'npub'
const first4 = str.slice(prefix.length, prefix.length + 4)
const last4 = str.slice(-4)
return `${prefix}:${first4}...${last4}`
}, [])
const handleClick = () => {
if (onChangeChannel) {
onChangeChannel(pubKey)
}
}
return (
<div
className={`d-flex align-items-center p-2 rounded-3 mb-2 cursor-pointer ${isSelected ? 'bg-primary text-white' : ''}`}
style={{
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isSelected ? '#0d6efd' : 'transparent',
border: '1px solid transparent'
}}
onClick={handleClick}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}
}}
>
{/* User Avatar */}
<div className='me-3 flex-shrink-0 d-flex align-items-center'>
{profile?.picture
? (
<img
src={profile.picture}
alt={`${profile.name || 'User'} Avatar`}
className='rounded-circle'
style={{ width: '32px', height: '32px' }}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
)
: <FontAwesomeIcon
icon={faUser}
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '32px',
height: '32px',
backgroundColor: profile?.picture ? 'transparent' : '#e9ecef',
color: profile?.picture ? 'transparent' : '#6c757d',
fontSize: '14px',
display: profile?.picture ? 'none' : 'flex'
}}
/>}
</div>
{/* User Info */}
<div className='flex-grow-1 min-w-0'>
<div
className={`${isSelected ? 'text-white' : 'text-dark'} ${dm.unreadCount > 0 ? 'fw-bold' : 'fw-medium'}`}
style={{ fontSize: '14px' }}
>
{getShortName(profile?.name) || 'Unknown User'}
{!profile?.name && <Spinner animation='border' size='sm' style={{ marginLeft: '5px' }} />}
</div>
{/* <div
className={`small ${isSelected ? 'text-white-50' : 'text-muted'} ${dm.unreadCount > 0 ? 'fw-bold' : ''}`}
style={{ fontSize: '12px' }}
>
No messages yet
</div> */}
</div>
{/* Unread indicator */}
{/* {dm.unreadCount > 0 && (
<div className='ms-2'>
<span
className='badge rounded-pill'
style={{
backgroundColor: isSelected ? '#ffffff' : '#dc3545',
color: isSelected ? '#0d6efd' : '#ffffff',
fontSize: '10px',
minWidth: '18px'
}}
>
{dm.unreadCount > 99 ? '99+' : dm.unreadCount}
</span>
</div>
)} */}
</div>
)
}
@@ -0,0 +1,50 @@
/*
Component for displaying a list of direct messages
*/
// Global npm libraries
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import DMItem from './dm-item'
import { Spinner } from 'react-bootstrap'
function DMList (props) {
const { dmChannels, profiles, selectedChannel, onChangeChannel, dmListLoaded } = props
if (dmChannels.length === 0 && dmListLoaded) {
return (
<div className='text-center p-3'>
<div className='text-muted mb-2'>
<FontAwesomeIcon icon={faUser} style={{ fontSize: '24px', opacity: 0.5 }} />
</div>
<div className='text-muted small'>No direct messages yet</div>
<div className='text-muted small' style={{ fontSize: '11px' }}>
Start a conversation to see it here
</div>
</div>
)
}
return (
<div className='dm-list'>
{dmChannels.map((pubKey) => (
<DMItem
key={pubKey}
dm={{ pubKey }}
profiles={profiles}
selectedChannel={selectedChannel}
onChangeChannel={onChangeChannel}
{...props}
/>
))}
{!dmListLoaded && (
<div className='text-center'>
<Spinner animation='border' />
</div>
)}
</div>
)
}
export default DMList
+557
View File
@@ -0,0 +1,557 @@
/*
Component for Nostr Chat functionality
*/
// Global npm libraries
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import NostrRestClient, { generateSubId } from '../../../services/nostr-rest-client.js'
// Local libraries
import ChatSidebar from './chat-sidebar'
import ChatMain from './chat-main'
import config from '../../../config'
// Global variables and constants
function NostrChat (props) {
const { appData } = props
const { nostrQueries, bchWalletState, startChannelChat } = appData
// Initialize REST client for SSE subscriptions
const restClient = useRef(new NostrRestClient())
// Track active subscriptions for cleanup
const subscriptionsRef = useRef({})
const [messages, setMessages] = useState([])
const [loadedMessages, setLoadedMessages] = useState(false)
const [profiles, setProfiles] = useState({})
const [channelsData, setChannelsData] = useState({})
const [channelsLoaded, setChannelsLoaded] = useState(false)
const [groupChannels] = useState(config.chatsId)
const [dmChannels, setDmChannels] = useState([])
const [dmListLoaded, setDmListLoaded] = useState(false)
const [selectedChannel, setSelectedChannel] = useState(null)
const [selectedChannelIsDm, setSelectedChannelIsDm] = useState(false)
const [deletedChats] = useState(appData.nostrQueries.deletedChats)
const profilesRef = useRef({})
const dmChannelsRef = useRef([])
// Reset states on change channel
const onChangeChannel = useCallback((ch) => {
if (selectedChannel === ch) return
const profiles = profilesRef.current
setSelectedChannelIsDm(!!profiles[ch])
setMessages([])
setLoadedMessages(false)
setSelectedChannel(ch)
}, [selectedChannel])
// Add a new DM to the list
const addPrivateMessage = useCallback(async (profile) => {
try {
const exist = dmChannelsRef.current.find(val => val === profile.pubKey)
setMessages([])
setLoadedMessages(false)
onChangeChannel(profile.pubKey)
if (exist) return
setDmChannels(currentChs => {
let newChs = [...currentChs]
newChs.push(profile.pubKey)
newChs = newChs.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
dmChannelsRef.current = newChs
return newChs
})
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[profile.pubKey] = profile
return newChs
})
} catch (error) {
console.warn(error)
}
}, [onChangeChannel])
// Define starter chat
useEffect(() => {
// Start dm if a initial chat is provided from props
const startDM = async (pubKey) => {
const npub = await appData.nostrQueries.hexToNpub(pubKey)
const defaultProfile = { name: npub } // default profile
profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again.
// Fetch profile
const nostrProfile = await appData.nostrQueries.getProfile(pubKey)
const profile = nostrProfile || defaultProfile
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
// Update profiles state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
await addPrivateMessage(profile)
}
if (selectedChannel) return
if (!startChannelChat) {
// Set group chat as initial chat
setSelectedChannel(config.chatsId[0])
setSelectedChannelIsDm(false)
} else if (dmListLoaded) {
// Set provided profile as initial chat
startDM(startChannelChat)
}
}, [appData, startChannelChat, dmListLoaded, addPrivateMessage, selectedChannel])
// Handle read messages
const onMsgRead = useCallback(async (ev) => {
try {
// console.log('onMsgRead() msg: ', msg)
// Update messages list
setMessages(current => {
// ignore existing messages
const exist = current.find(val => val.id === ev.id)
if (exist) return current
const newMsgs = [...current]
newMsgs.push(ev)
// Sort messages by timestamp
newMsgs.sort((a, b) => b.created_at - a.created_at)
return newMsgs.reverse()
})
// Fetch message owner profile
const pubKey = ev.pubkey
const existProfile = profilesRef.current[ev.pubkey]
if (existProfile) {
return
}
const npub = await appData.nostrQueries.hexToNpub(pubKey)
console.log(`Trying to get ${pubKey} profile.`)
const defaultProfile = { name: npub } // default profile
profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again.
// Fetch profile
const nostrProfile = await appData.nostrQueries.getProfile(pubKey)
const profile = nostrProfile || defaultProfile
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
// Update profiles state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
} catch (error) {
console.warn(error)
}
}, [appData, profilesRef])
const decryptMsg = useCallback(async ({ ev, pubKey }) => {
try {
const encryptedMsg = ev.content
const senderPubKey = pubKey
const { nostrKeyPair } = appData.bchWalletState
const { nostrQueries } = appData
const decryptData = {
receiverPrivKey: nostrKeyPair.privHex,
senderPubKey,
encryptedMsg
}
const decrptedMsg = await nostrQueries.decryptMsg(decryptData)
ev.content = decrptedMsg
onMsgRead(ev)
} catch (error) {
console.warn(error)
}
}, [appData, onMsgRead])
// Handle SSE subscription for group channels
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !channelsLoaded || selectedChannelIsDm) return
// wait for deleted chats
if (!deletedChats || !Array.isArray(deletedChats)) return
// Create subscription for group channel messages
const subId = generateSubId(`group-${selectedChannel}`)
const filter = { limit: 10, kinds: [42], '#e': [selectedChannel] }
// Track if EOSE has been called
let eoseCalled = false
let eoseTimeoutId = null
const subscription = restClient.current.createSubscription(subId, filter, {
onEvent: (ev) => {
console.log('Group post retrieved from REST API', ev.content)
const onBlackList = nostrQueries.blackList.find((val) => { return val === ev.pubkey })
const isDeleted = deletedChats.find((val) => { return val.eventId === ev.id })
if (!onBlackList && !isDeleted) {
onMsgRead(ev)
}
},
onEose: () => {
eoseCalled = true
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
eoseTimeoutId = null
}
if (!selectedChannelIsDm) {
// Use setTimeout to ensure state updates from onEvent callbacks are processed
// before setting loadedMessages to true
setTimeout(() => {
setLoadedMessages(true)
}, 100)
}
},
onClosed: (message) => {
console.log('Group channel subscription closed:', message)
},
onError: (error) => {
console.warn('Group channel subscription error:', error)
}
})
subscriptionsRef.current[subId] = subscription
// Set up EOSE timeout fallback - if EOSE doesn't arrive within 10 seconds, set loadedMessages anyway
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
eoseTimeoutId = setTimeout(() => {
if (!eoseCalled && !selectedChannelIsDm) {
console.warn(`EOSE timeout reached for subscription ${subId} - setting loadedMessages to true`)
setLoadedMessages(true)
}
}, EOSE_TIMEOUT_MS)
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
return () => {
// Clear EOSE timeout if it exists
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
}
// Close subscription on component unmount or selected channel changes
console.log('Close existing subscription for group channel')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, channelsLoaded, deletedChats])
// Handle SSE subscription for dm channels
useEffect(() => {
// fetch messages when channel selected and channel metadata are loaded
if (!selectedChannel || !selectedChannelIsDm) return
const { nostrKeyPair } = bchWalletState
const dmPubKey = selectedChannel
// Create subscription for DM channel messages
const subId = generateSubId(`dm-${dmPubKey}`)
// Use array of filters for multiple conditions
const filters = [
{ limit: 10, kinds: [4], '#p': [nostrKeyPair.pubHex], authors: [dmPubKey] }, // received messages
{ limit: 10, kinds: [4], '#p': [dmPubKey], authors: [nostrKeyPair.pubHex] } // sent messages
]
// Track if EOSE has been called
let eoseCalled = false
let eoseTimeoutId = null
const subscription = restClient.current.createSubscription(subId, filters, {
onEvent: (ev) => {
console.log('DM post retrieved from REST API', ev.content)
// decrypt message
if (ev.pubkey === nostrKeyPair.pubHex) {
// Sent messages
decryptMsg({ ev, pubKey: dmPubKey })
} else {
// Received messages
decryptMsg({ ev, pubKey: ev.pubkey })
}
},
onEose: () => {
eoseCalled = true
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
eoseTimeoutId = null
}
if (selectedChannelIsDm) {
// Use setTimeout to ensure state updates from onEvent callbacks are processed
// before setting loadedMessages to true
setTimeout(() => {
setLoadedMessages(true)
}, 100)
}
},
onClosed: (message) => {
console.log('DM channel subscription closed:', message)
},
onError: (error) => {
console.warn('DM channel subscription error:', error)
}
})
subscriptionsRef.current[subId] = subscription
// Set up EOSE timeout fallback - if EOSE doesn't arrive within 10 seconds, set loadedMessages anyway
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
eoseTimeoutId = setTimeout(() => {
if (!eoseCalled && selectedChannelIsDm) {
console.warn(`EOSE timeout reached for subscription ${subId} - setting loadedMessages to true`)
setLoadedMessages(true)
}
}, EOSE_TIMEOUT_MS)
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
return () => {
// Clear EOSE timeout if it exists
if (eoseTimeoutId) {
clearTimeout(eoseTimeoutId)
}
// Close subscription on component unmount or selected channel changes
console.log('Close existing subscription for private channel')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
}
}, [onMsgRead, selectedChannel, selectedChannelIsDm, nostrQueries, bchWalletState, decryptMsg])
const handleIncomingDms = useCallback(async (pubKey) => {
try {
if (!dmListLoaded) return
const exist = dmChannelsRef.current.find(val => val === pubKey)
if (exist) return
let profile = await nostrQueries.getProfile(pubKey)
const npub = nostrQueries.hexToNpub(pubKey)
if (!profile) profile = { name: npub }
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
setDmChannels(currentChs => {
const newChs = [...currentChs]
newChs.push(profile.pubKey)
dmChannelsRef.current = newChs
return newChs
})
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[profile.pubKey] = profile
return newChs
})
} catch (error) {
console.warn(error)
}
}, [nostrQueries, dmListLoaded])
// Keep live subscription for new DMs
useEffect(() => {
if (!dmListLoaded || !channelsLoaded) return
const { bchWalletState } = appData
const { nostrKeyPair } = bchWalletState
// Create subscription for new incoming DM notifications
const subId = generateSubId('dm-notify')
const filter = { limit: 0, kinds: [4], '#p': [nostrKeyPair.pubHex] } // received messages
const subscription = restClient.current.createSubscription(subId, filter, {
onEvent: (ev) => {
console.log('New message received from REST API', ev)
handleIncomingDms(ev.pubkey)
},
onEose: () => {
// EOSE received, subscription is active
},
onClosed: (message) => {
console.log('DM notification subscription closed:', message)
},
onError: (error) => {
console.warn('DM notification subscription error:', error)
}
})
subscriptionsRef.current[subId] = subscription
// Capture values for cleanup
const subscriptionsRefValue = subscriptionsRef.current
const restClientValue = restClient.current
return () => {
// Close subscription on component unmount
console.log('Close existing subscription for DM notifications')
if (subscriptionsRefValue[subId]) {
subscriptionsRefValue[subId].close()
delete subscriptionsRefValue[subId]
}
restClientValue.closeSubscription(subId).catch(err => {
// Subscription already closed is not an error - this is expected behavior
const errorMessage = err?.message || err?.toString() || ''
if (!errorMessage.includes('not found') && !errorMessage.includes('already closed')) {
console.warn('Error closing subscription:', err)
}
})
}
}, [handleIncomingDms, appData, nostrQueries, dmListLoaded, channelsLoaded])
// Load Dm channels
useEffect(() => {
const loadCurrentDms = async () => {
const { nostrQueries, bchWalletState } = appData
const { nostrKeyPair } = bchWalletState
const dms = await appData.nostrQueries.getDms(nostrKeyPair.pubHex)
setDmChannels(currentChs => {
let newChs = [...currentChs, ...dms]
newChs = newChs.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
dmChannelsRef.current = newChs
return newChs
})
setDmListLoaded(true)
console.log('dm list', dms)
for (let i = 0; i < dms.length; i++) {
const pubKey = dms[i]
const npub = await nostrQueries.hexToNpub(pubKey)
const defaultProfile = { name: npub } // default profile
profilesRef.current[pubKey] = defaultProfile // update ref , to prevent fetch this profile again.
// Fetch profile
const nostrProfile = await nostrQueries.getProfile(pubKey)
const profile = nostrProfile || defaultProfile
// add public key formats to profile object
profile.pubKey = pubKey
profile.npub = npub
// Update profiles state
setProfiles(currentProfiles => {
const newProfiles = { ...currentProfiles }
newProfiles[pubKey] = profile
profilesRef.current = newProfiles
return newProfiles
})
}
}
if (!dmListLoaded) loadCurrentDms()
}, [appData, dmListLoaded])
// Load public channels data
useEffect(() => {
const loadChData = async () => {
const loadedChannels = []
for (let i = 0; i < groupChannels.length; i++) {
const ch = groupChannels[i]
const exist = loadedChannels.find((val) => { return val === ch })
if (exist) { continue }
// Fech profile.
let channelData = await appData.nostrQueries.getChannelInfo(ch)
console.log('channelData', channelData)
// Set short id as name
if (!channelData) channelData = { name: ch.slice(0, 8) + '...' + ch.slice(-5) }
loadedChannels.push(channelData)
// Update channels data state
setChannelsData(currentChs => {
const newChs = { ...currentChs }
newChs[ch] = channelData
return newChs
})
setChannelsLoaded(true)
}
}
loadChData()
}, [selectedChannel, appData, groupChannels])
return (
<>
<Container fluid className='h-100 p-0 mb-5 '>
<Row className='h-100 g-0'>
<Col xs={12} md={4} lg={3} className='h-100'>
<ChatSidebar
groupChannels={groupChannels}
dmChannels={dmChannels}
selectedChannel={selectedChannel}
selectedChannelIsDm={selectedChannelIsDm}
profiles={profiles}
channelsData={channelsData}
onChangeChannel={onChangeChannel}
dmListLoaded={dmListLoaded && channelsLoaded}
{...props}
/>
</Col>
<Col xs={12} md={8} lg={9} className='h-100 pe-2'>
<ChatMain
loadedMessages={loadedMessages}
selectedChannel={selectedChannel}
selectedChannelIsDm={selectedChannelIsDm}
messages={messages}
profiles={profiles}
channelsData={channelsData}
dmListLoaded={dmListLoaded && channelsLoaded}
onChangeChannel={onChangeChannel}
addPrivateMessage={addPrivateMessage}
{...props}
/>
</Col>
</Row>
</Container>
</>
)
}
export default NostrChat
@@ -0,0 +1,180 @@
/*
Component for the message input area
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Form, Button, InputGroup } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPaperPlane } from '@fortawesome/free-solid-svg-icons'
import { finalizeEvent } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import NostrRestClient from '../../../services/nostr-rest-client.js'
function MessageInput (props) {
const { appData, selectedChannel, profiles } = props
const { bchWalletState, nostrQueries } = appData
// Initialize REST client for publishing
const restClient = new NostrRestClient()
const [isDm, setIsDm] = useState(false)
const [dmProfile, setDmProfile] = useState(false)
const [message, setMessage] = useState('')
const [onFetch, setOnFetch] = useState(false)
// Define input type between private or public
useEffect(() => {
const dmTo = profiles[selectedChannel]
setIsDm(!!dmTo)
setDmProfile(dmTo)
}, [selectedChannel, profiles])
const handleSubmitPrivate = async (e) => {
e.preventDefault()
try {
setOnFetch(true)
console.log('dm To : ', dmProfile)
const { nostrKeyPair } = bchWalletState
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
const encryptedMsg = await nostrQueries.encryptMsg({
senderPrivKey: nostrKeyPair.privHex,
receiverPubKey: dmProfile?.pubKey,
message
})
console.log('encryptedMsg', encryptedMsg)
// Generate a post.
const eventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', dmProfile.pubKey]],
content: encryptedMsg
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the message via REST API (handles broadcasting to multiple relays)
try {
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish: ${result.message || 'Unknown error'}`)
}
} catch (err) {
console.warn(`Error publishing message: ${err}`)
throw err
}
setMessage('')
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
// Post on nostr network
const handleSubmitPublic = async (e) => {
e.preventDefault()
try {
setOnFetch(true)
const { nostrKeyPair } = 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: 42,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', selectedChannel, 'root']],
content: message
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the message via REST API (handles broadcasting to multiple relays)
try {
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish: ${result.message || 'Unknown error'}`)
}
} catch (err) {
console.warn(`Error publishing message: ${err}`)
throw err
}
setMessage('')
setOnFetch(false)
} catch (error) {
console.warn(error)
setOnFetch(false)
}
}
const onChange = (e) => {
setMessage(e.target.value)
}
return (
<div className='p-3' style={{ backgroundColor: '#ffffff' }}>
<Form onSubmit={isDm ? handleSubmitPrivate : handleSubmitPublic}>
<InputGroup>
<Form.Control
as='textarea'
rows={3}
placeholder='Type a message...'
className='border-1 bg-transparent text-dark'
style={{
backgroundColor: '#f8f9fa',
border: '4px solid rgb(235, 232, 232)',
borderRadius: '12px',
padding: '12px 16px',
fontSize: '14px',
resize: 'none',
minHeight: '60px',
maxHeight: '120px'
}}
value={message}
onChange={onChange}
disabled={onFetch}
/>
<Button
type='submit'
variant='link'
className='text-dark border-0 bg-transparent ms-2'
style={{
color: '#6c757d',
transition: 'color 0.2s ease',
alignSelf: 'flex-end',
marginBottom: '8px'
}}
title='Send message'
disabled={onFetch}
>
<FontAwesomeIcon icon={faPaperPlane} />
</Button>
</InputGroup>
</Form>
</div>
)
}
export default MessageInput
@@ -0,0 +1,123 @@
/*
Component for displaying a single chat message
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
import { Spinner } from 'react-bootstrap'
import NostrFormat from '../../app-body/nostr/nostr-format'
import ProfileMenu from './profile-menu'
function MessageItem (props) {
const { message, profiles, selectedChannel } = props
const [profile, setProfile] = useState(null)
const [isDm, setIsDm] = useState(false)
// Define type between private or public
useEffect(() => {
const dmTo = profiles[selectedChannel]
setIsDm(!!dmTo)
}, [selectedChannel, profiles])
// Format timestamp
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
useEffect(() => {
if (profiles[message.pubkey]) {
setProfile(profiles[message.pubkey])
}
}, [profiles, message])
return (
<div className='mb-3 d-flex align-items-start'>
{/* Message Content */}
<div className='flex-grow-1'>
<div
className='text-dark p-3 rounded-3'
style={{
backgroundColor: '#ffffff',
border: '1px solid #e9ecef',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
maxWidth: '85%',
wordWrap: 'break-word'
}}
>
<div className='d-flex align-items-center mb-2'>
{/* Avatar */}
<div className='me-2 flex-shrink-0'>
{profile?.picture
? (
<ProfileMenu
profile={profile}
isDm={isDm}
{...props}
>
<img
src={profile.picture}
alt={`${profile.name} Avatar`}
className='rounded-circle'
style={{ width: '24px', height: '24px' }}
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'block'
}}
/>
</ProfileMenu>
)
: (
<ProfileMenu
profile={profile}
isDm={isDm}
{...props}
>
<FontAwesomeIcon
icon={faUser}
className='rounded-circle d-flex align-items-center justify-content-center'
style={{
width: '24px',
height: '24px',
backgroundColor: '#e9ecef',
color: '#6c757d',
fontSize: '12px',
display: message.avatar ? 'none' : 'flex'
}}
/>
</ProfileMenu>
)}
</div>
{!profile && <span className='fw-bold text-dark me-2'>{message.pubkey}<Spinner animation='border' size='sm' /></span>}
{profile && profile.name && (
<ProfileMenu
profile={profile}
isDm={isDm}
{...props}
>
<span className='fw-bold text-dark me-2'>
{profile.name}
</span>
</ProfileMenu>
)}
<small className='text-muted'>{formatTime(message.created_at * 1000)}</small>
</div>
<div style={{ lineHeight: '1.4', wordBreak: 'break-all' }}>
<NostrFormat content={message.content} />
</div>
</div>
</div>
</div>
)
}
export default MessageItem
@@ -0,0 +1,110 @@
/*
Component for displaying the list of chat messages
*/
// Global npm libraries
import React, { useEffect, useState, useCallback, useRef } from 'react'
// Local libraries
import MessageItem from './message-item'
import DateSeparator from './date-separator'
import { Spinner } from 'react-bootstrap'
function MessageList (props) {
const { messages, loadedMessages } = props
console.log('loadedMessages', loadedMessages)
const [groupedMessages, setGroupedMessages] = useState({})
const msgContainerRef = useRef()
// Group messages by date
const groupMessagesByDate = useCallback((messages) => {
const grouped = {}
messages.forEach((message, i) => {
const date = new Date(message.created_at * 1000).toDateString()
if (!grouped[date]) {
grouped[date] = []
}
grouped[date].push(message)
})
return grouped
}, [])
useEffect(() => {
const grouped = groupMessagesByDate(messages)
setGroupedMessages(grouped)
// Scroll to end of the message container.
// Wait few seconds before load render.
setTimeout(() => {
if (msgContainerRef.current) {
msgContainerRef.current.scrollTo({
top: msgContainerRef.current.scrollHeight - msgContainerRef.current.clientHeight,
behavior: 'smooth'
})
msgContainerRef.current = null // scroll one time
}
}, 1000)
}, [messages, groupMessagesByDate])
return (
<>
<style>
{`
.hide-scrollbar::-webkit-scrollbar {
width: 0 !important;
display: none !important;
}
.hide-scrollbar {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
`}
</style>
<div
className='h-100 overflow-auto hide-scrollbar'
style={{
backgroundColor: '#f8f9fa',
height: 'calc(100vh - 200px)',
maxHeight: 'calc(100vh - 200px)'
}}
>
{loadedMessages && (!messages || messages.length === 0) && (
<div className='h-100 d-flex align-items-center justify-content-center ' style={{ minHeight: '50vh' }}>
<div className='text-center text-muted'>
<p>No messages yet. Start the conversation!</p>
</div>
</div>
)}
{loadedMessages && messages && messages.length > 0 && (
<div ref={msgContainerRef} className='p-3' style={{ overflowY: 'auto', maxHeight: '50vh' }}>
{Object.entries(groupedMessages).map(([date, dateMessages]) => (
<div key={date}>
<DateSeparator date={date} />
{dateMessages.map((message, index) => (
<MessageItem
key={`${message.id}-${index}`}
message={message}
totalMessages={messages.length}
messageIndex={index}
{...props}
/>
))}
</div>
))}
</div>
)}
{!loadedMessages &&
<div className='p-3' style={{ minHeight: '50vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spinner animation='border' size='md' />
</div>}
</div>
</>
)
}
export default MessageList
@@ -0,0 +1,66 @@
/*
Component for displaying a profile menu
*/
// Global npm libraries
import React from 'react'
import Dropdown from 'react-bootstrap/Dropdown'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faMessage, faUser } from '@fortawesome/free-solid-svg-icons'
function ProfileMenu (props) {
const { profile, children, addPrivateMessage, isDm, dmListLoaded } = props
const handlePrivateMessage = () => {
addPrivateMessage(profile)
}
const handleUserProfile = () => {
const profileUrl = `${window.location.origin}/profile/${profile.npub}#single-view`
window.open(profileUrl, '_blank')
}
return (
<>
<style>
{`
.dropdown-toggle-no-arrow::after {
display: none !important;
}
`}
</style>
<Dropdown>
<Dropdown.Toggle
as='div'
className='d-inline-block dropdown-toggle-no-arrow'
style={{
cursor: 'pointer',
'--bs-btn-padding-x': '0',
'--bs-btn-padding-y': '0'
}}
>
{children}
</Dropdown.Toggle>
<Dropdown.Menu className='shadow-sm' style={{ minWidth: '180px' }}>
<Dropdown.Header className='text-muted fw-bold'>
Profile Options
</Dropdown.Header>
{!isDm && (
<Dropdown.Item onClick={handlePrivateMessage} disabled={!dmListLoaded}>
<FontAwesomeIcon icon={faMessage} className='me-2' />
Private Message
</Dropdown.Item>
)}
<Dropdown.Item onClick={handleUserProfile}>
<FontAwesomeIcon icon={faUser} className='me-2' />
User Profile
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
)
}
export default ProfileMenu
@@ -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,140 @@
/*
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 } from 'react-bootstrap'
import axios from 'axios'
// Local libraries
import config from '../../../../config'
import ContentCard from './content-card'
import FilterDropdown from './filter-dropdown'
// 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 {
console.log('loadCreators()')
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 profileRes = await appData.nostrQueries.getProfile(creator.pubkey)
let profile = profileRes
if (!profileRes) profile = {}
// 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, 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'>
<FilterDropdown
selectedFilter={selectedFilter}
setSelectedFilter={setSelectedFilter}
/>
</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,31 @@
import React from 'react'
import { Dropdown } from 'react-bootstrap'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
function FilterDropdown (props) {
const { selectedFilter, setSelectedFilter } = props
return (
<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>
)
}
export default FilterDropdown
@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react'
import { Spinner } from 'react-bootstrap'
import { finalizeEvent } from 'nostr-tools/pure'
import { hexToBytes } from '@noble/hashes/utils'
import NostrRestClient from '../../../../services/nostr-rest-client.js'
function FollowBtn (props) {
const [onFetch, setOnFetch] = useState(false)
const [isFollowing, setIsFollowing] = useState(false)
const { creator, appData, creatorProfile, followList, refreshFollowList } = props
// Initialize REST client for publishing
const restClient = new NostrRestClient()
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 (relay URL in tag is optional, REST API handles relay selection)
currentList.push(['p', creator.pubkey, '', 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)
// Generate a follow list event.
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)
// Publish the follow list via REST API (handles broadcasting to multiple relays)
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish follow list: ${result.message || 'Unknown error'}`)
}
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,258 @@
/**
* 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 { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import NostrRestClient from '../../../../services/nostr-rest-client.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
// Initialize REST client for publishing
const restClient = new NostrRestClient()
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)}`)
// Sign the post
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Publish the like via REST API (handles broadcasting to multiple relays)
try {
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish like: ${result.message || 'Unknown error'}`)
}
} catch (err) {
console.warn(`Error publishing like: ${err}`)
throw 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,62 @@
/**
* Component for displaying following nostr posts
*/
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container, Spinner } from 'react-bootstrap'
// Local libraries
import FeedCard from './feed-card'
function Following (props) {
const { appData, posts, profiles } = props
const { nostrQueries } = appData
const [followingPosts, setFollowingPosts] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const getFollowingPosts = async () => {
console.log('getFollowingPosts')
const { nostrKeyPair } = appData.bchWalletState
const followingList = await nostrQueries.getFollowList(nostrKeyPair.pubHex)
// 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, nostrQueries])
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,244 @@
/*
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 { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import NostrRestClient from '../../../../services/nostr-rest-client.js'
// Local libraries
function ProfilePost (props) {
const { appData } = props
const { bchWalletState } = appData
// Initialize REST client for publishing
const restClient = new NostrRestClient()
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 profile via REST API (handles broadcasting to multiple relays)
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish profile: ${result.message || 'Unknown error'}`)
}
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,144 @@
/*
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 { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import NostrRestClient from '../../../../services/nostr-rest-client.js'
// Local libraries
function PublicPost (props) {
const [accordionKey, setAccordionKey] = useState('0')
const [onFetch, setOnFetch] = useState(false)
const { bchWalletState } = props.appData
// Initialize REST client for publishing
const restClient = new NostrRestClient()
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 via REST API (handles broadcasting to multiple relays)
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
if (!result.accepted) {
throw new Error(`Failed to publish post: ${result.message || 'Unknown error'}`)
}
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,61 @@
/*
Component for read nostr information.
*/
// Global npm libraries
import React, { useState, useCallback, useEffect } from 'react'
import config from '../../../../config/index.js'
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'
import axios from 'axios'
function Profile (props) {
const { wallet, bchWalletState } = props.appData
const { nostrKeyPair } = bchWalletState
const [profile, setProfile] = useState(false)
const [profileAddresses, setProfileAddresses] = useState(null)
const { npub } = useParams()
const getAddressByNpub = useCallback(async () => {
try {
// Validate if provided npub match with user npub
if (npub === nostrKeyPair.npub) {
// Use user addresses
setProfileAddresses({
bchAddr: bchWalletState.cashAddress,
slpAddr: bchWalletState.slpAddress
})
return
}
// Fetch addresses for external npub.
const url = `${config.dexServer}/sm/npub/${npub}`
const response = await axios.get(url)
const bchAddr = response.data.bchAddr
const slpAddr = wallet.bchjs.SLP.Address.toSLPAddress(bchAddr)
setProfileAddresses({ bchAddr, slpAddr })
} catch (error) {
console.error(error)
}
}, [npub, wallet, nostrKeyPair, bchWalletState])
useEffect(() => {
getAddressByNpub()
}, [getAddressByNpub])
return (
<>
<Container>
<ProfileRead {...props} onProfileRead={setProfile} npub={npub} profileAddresses={profileAddresses} />
<PublicRead {...props} profile={profile} npub={npub} profileAddresses={profileAddresses} />
<SlpTokensDisplay {...props} npub={npub} profileAddresses={profileAddresses} />
{profileAddresses && <NFTForSale {...props} npub={npub} profileAddresses={profileAddresses} />}
</Container>
</>
)
}
export default Profile
@@ -0,0 +1,322 @@
/**
* 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, profileAddresses } = 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)
// Handler for refresh button
const handleRefresh = () => {
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 { bchAddr } = profileAddresses
if (!bchAddr) return
const url = `${config.dexServer}/offer/list/addr/${bchAddr}`
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, profileAddresses])
// 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.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 {
if (!profileAddresses?.bchAddr) return
// 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, profileAddresses])
// 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,197 @@
/**
* 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'
import { useNavigate } from 'react-router-dom'
// Local libraries
import NostrFormat from '../nostr-format'
function ProfileRead (props) {
const { npub, appData, onProfileRead, profileAddresses } = props
const [profile, setProfile] = useState({})
const [pubKey, setPubKey] = useState({})
const [loaded, setLoaded] = useState(false)
const [imageError, setImageError] = useState({ picture: false, banner: false })
const navigate = useNavigate()
useEffect(() => {
const start = async () => {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
setPubKey(pubHex)
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 }))
}
const handleMessage = () => {
console.log('appData', appData)
const { setStartChannelChat } = appData
setStartChannelChat(pubKey)
navigate('/nostr-chat')
}
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>
{profileAddresses?.bchAddr && (
<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'>
{`${profileAddresses?.bchAddr?.slice(0, 8)}...${profileAddresses?.bchAddr?.slice(-5)}`}
</span>
<span className='d-none d-md-inline'>
{profileAddresses?.bchAddr}
</span>
</span>
<CopyOnClick walletProp='bchAddr' appData={props.appData} value={profileAddresses?.bchAddr} />
</div>
)}
{profileAddresses?.slpAddr && (
<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'>
{`${profileAddresses?.slpAddr?.slice(0, 8)}...${profileAddresses?.slpAddr?.slice(-5)}`}
</span>
<span className='d-none d-md-inline'>
{profileAddresses?.slpAddr}
</span>
</span>
<CopyOnClick walletProp='slpAddr' appData={props.appData} value={profileAddresses?.slpAddr} />
</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' }}
onClick={handleMessage}
disabled={!loaded}
>
<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
@@ -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
+238
View File
@@ -0,0 +1,238 @@
/*
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 } = props
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
const [tokens, setTokens] = useState([])
const refreshTokenButtonRef = React.useRef()
const { slpInitLoaded, asyncBackgroundFinished } = props.appData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
const backgroundDataError = !slpInitLoaded && asyncBackgroundFinished
// 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 () => {
await refreshTokenButtonRef.current.handleRefreshTokenBalance()
}
// 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 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.userData = userData
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenMutableData])
const loadData = useCallback(async () => {
const tokens = appData.bchWalletState.slpTokens
console.log('tokens', tokens)
setTokens(tokens)
await lazyLoadTokenData(tokens)
await lazyLoadMutableData(tokens)
}, [appData, lazyLoadTokenData, lazyLoadMutableData])
// Start to load the token icons when the component is mounted
useEffect(() => {
if (slpInitLoaded) {
loadData()
}
}, [loadData, slpInitLoaded])
// 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={loadData}
/>
</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>
{appData.asyncInitSucceeded && (
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
!backgroundDataLoaded && !backgroundDataError && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but data is not loaded */
!backgroundDataError && !dataAreLoaded && tokens.length > 0 && (
<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 */
backgroundDataLoaded && 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>
<br />
<Row>
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{backgroundDataLoaded && !backgroundDataError && tokens.length === 0 && (
<Row className='text-center'>
<span> No tokens found in wallet </span>
</Row>
)}
{backgroundDataError && (
<Row style={{ color: 'red' }} className='text-center'>
<span>Tokens could not be loaded! </span>
</Row>
)}
</Container>
</>
)
}
export default SlpTokens
@@ -0,0 +1,145 @@
/*
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, useEffect } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } 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 [mutableDataCid, setMutableDataCid] = useState(null)
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)
// 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 {
console.log('props token', props.token)
const userDataStr = props.token.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])
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>
{!props.token.iconAlreadyDownloaded && (
<div className='text-center'>
<Spinner animation='border' size='sm' />
</div>
)}
{props.token.iconAlreadyDownloaded && 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,108 @@
/*
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)
const { slpInitLoaded, asyncBackgroundFinished } = initialAppData.asyncBackGroundInitState
// Background bch data loaded finished
const backgroundDataLoaded = slpInitLoaded || asyncBackgroundFinished
// 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 }
// if slpInitLoaded is 'false', them set as true , to show the new balance.
appData.updateBackGroundInitState({ slpInitLoaded: true })
// 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} disabled={!backgroundDataLoaded}>
<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
+192
View File
@@ -0,0 +1,192 @@
/*
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)
}
}
// 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>
</Container>
{showModal && getModal()}
</>
)
}
export default SweepWif
+127
View File
@@ -0,0 +1,127 @@
/*
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'
import '../../App.css'
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'>
<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
+132 -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,145 @@ 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 === '/fungible' ? 'nav-link-active' : 'nav-link-inactive'}
to='/fungible'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<NavLink
className={currentPath === '/counter-offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/counter-offers'
onClick={handleClickEvent}
>
Counter Offers
</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>
<NavLink
className={currentPath === '/nostr-chat' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-chat'
onClick={handleClickEvent}
>
Nostr Chat
</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>
</>
)
+2 -2
View File
@@ -20,8 +20,8 @@ export function UninitializedView (props = {}) {
denyClose={appData.denyClose}
/>
{
props.asyncInitFinished
? <AppBody menuState={100} wallet={appData.wallet} appData={appData} />
appData.asyncInitFinished
? <> <br /><AppBody menuState={100} wallet={appData.wallet} appData={appData} /></>
: null
}
</>
+23 -2
View File
@@ -11,8 +11,29 @@ 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',
dexServer: process.env.REACT_APP_DEX_SERVER || 'https://dex-api.fullstack.cash',
nostrTopic: 'bch-dex-test-topic-02',
// REST API endpoint for Nostr relay interactions (primary interface)
nostrRestApiUrl: 'https://nostr-relay-api.psfoundation.info',
// nostrRestApiUrl: 'http://localhost:5942',
// Legacy relay URLs kept for reference (may be used in tags, but actual connections use REST API)
nostrRelay: 'wss://nostr-relay.psfoundation.info',
nostrRelays: [
'wss://nostr-relay.psfoundation.info',
'wss://nos.lol',
'wss://relay.damus.io'
],
chatsId: [
'32b0ebc01c984008977fbe476e064946990df75525331b24b37bbba5e89f4039'
]
}
+139 -15
View File
@@ -1,20 +1,39 @@
import { useState } from 'react'
import { useQueryParam, StringParam } from 'use-query-params'
import { useRef, useState } from 'react'
// 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()
// Default local storage object
const localStorageDefault = {
serverUrl: 'https://free-bch.fullstack.cash', // Default server
relays: [
{ address: 'wss://nostr-relay.psfoundation.info', read: true, write: true },
{ address: 'wss://relay.damus.io', read: true, write: true }
],
nftData: {},
lastFeedTab: 'feed'
}
// Load Local storage Data
const [lsState, setLSState, { removeItem }] = useLocalStorageState('bchWalletState-template', {
ssr: true,
defaultValue: localStorageDefault
// Get the CashStack URL from query parameter or use default
let [restURL] = useQueryParam('restURL', StringParam)
if (!restURL) restURL = 'https://free-bch.fullstack.cash'
})
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 || localStorageDefault.lastFeedTab)
// Startup state management
const [asyncInitStarted, setAsyncInitStarted] = useState(false)
@@ -26,20 +45,40 @@ function useAppState () {
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isSingleView, setIsSingleView] = useState(false)
// Background process state
const [asyncBackGroundInitState, setAsyncBackGroundInitState] = useState({
bchInitLoaded: false, slpInitLoaded: false, asyncBackgroundFinished: false
})
// NFTs for sale stored data to improve performance
const [nftForSaleCacheData, setNftForSaleCacheData] = useState(lsState.nftData || {})
// Relays
const [relaysData, setRelaysData] = useState(lsState.relays || localStorageDefault.relays) // All relays data
const [readRelays, setReadRelays] = useState(relaysData.filter(relay => relay.read).map(relay => relay.address)) // Read relays
const [writeRelays, setWriteRelays] = useState(relaysData.filter(relay => relay.write).map(relay => relay.address)) // Write relays
// Nostr queries service (REST API handles relays server-side, pass empty array for compatibility)
const nostrQueriesRef = useRef(new NostrQueries({ relays: [] }))
// ProfileDM
const [startChannelChat, setStartChannelChat] = useState('')
// 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)
@@ -79,13 +118,78 @@ function useAppState () {
return newBchWalletState
})
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
} catch (err) {
console.error('Error in App.js updateBchWalletState()')
throw err
}
}
// 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
}
// Update relays data (kept for UI compatibility, REST API handles relays server-side)
function updateRelaysData (relaysData) {
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData }) // Update the local storage
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
// NostrQueries no longer needs relay updates (REST API handles relays server-side)
// Recreate instance for compatibility, but pass empty array
nostrQueriesRef.current = new NostrQueries({ relays: [] })
}
// Restore relays data (kept for UI compatibility, REST API handles relays server-side)
function restoreRelaysData () {
const relaysData = [...localStorageDefault.relays] // Create a new array in order to detect changes
setRelaysData(relaysData)
updateLocalStorage({ relays: relaysData })
// get the relay addresses with read set to true
const readRelays = relaysData.filter(relay => relay.read).map(relay => relay.address)
setReadRelays(readRelays)
console.log('readRelays: ', readRelays)
// get the relay addresses with write set to true
const writeRelays = relaysData.filter(relay => relay.write).map(relay => relay.address)
setWriteRelays(writeRelays)
console.log('writeRelays: ', writeRelays)
// NostrQueries no longer needs relay updates (REST API handles relays server-side)
// Recreate instance for compatibility, but pass empty array
nostrQueriesRef.current = new NostrQueries({ relays: [] })
}
// Update background state
function updateBackGroundInitState (inObj = {}) {
try {
setAsyncBackGroundInitState(oldState => {
// console.log('background old state: ', oldState)
const state = Object.assign({}, oldState, inObj)
// console.log('background state: ', state)
return state
})
// console.log(`New wallet state: ${JSON.stringify(bchWalletState, null, 2)}`)
} catch (err) {
console.error('Error in App.js updateBackGroundInitState()')
throw err
}
}
return {
serverUrl,
setServerUrl,
@@ -117,8 +221,28 @@ 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: nostrQueriesRef.current,
relaysData,
updateRelaysData,
restoreRelaysData,
readRelays,
writeRelays,
startChannelChat,
setStartChannelChat,
asyncBackGroundInitState,
updateBackGroundInitState
}
}
+210 -8
View File
@@ -4,9 +4,20 @@
// 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'
import config from '../config'
const SERVER = `${config.dexServer}/`
class AsyncLoad {
constructor () {
@@ -56,8 +67,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) {
@@ -77,9 +94,18 @@ class AsyncLoad {
// Get the BCH balance of the wallet.
async getWalletBchBalance (wallet, updateBchWalletState, appData) {
try {
/* // Force error for development
await sleep(6000)
throw new Error('getWalletBchBalance error')
*/
// 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 })
@@ -108,17 +134,14 @@ class AsyncLoad {
// Get a list of SLP tokens held by the wallet.
async getSlpTokenBalances (wallet, updateBchWalletState, appData) {
try {
/* // Force error for development
await sleep(6000)
throw new Error('getSlpTokenBalances error')
*/
// Get token information from the wallet. This will also initialize the UTXO store.
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 +226,185 @@ 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
}
}
async getDerivatedWallet (restURL, mnemonic, hdPath = "m/44'/245'/0'/0/0", initialize = true) {
try {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true,
hdPath
}
const wallet = new this.BchWallet(mnemonic, options)
// Wait for wallet to initialize.
await wallet.walletInfoPromise
if (initialize) {
await wallet.initialize()
}
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
throw error
}
}
// Get buyer and counter offer data.
async getCounterOfferMetadata (appData) {
try {
const { bchWalletState, serverUrl } = appData
// Start async load lib
const asyncLoad = new AsyncLoad()
await asyncLoad.loadWalletLib()
// Buyer wallet data
const buyerWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/0", false)
const buyerAddr = buyerWallet.walletInfo.cashAddress
const buyerKeyPair = asyncLoad.nostrKeyPairFromWIF(buyerWallet.walletInfo.privateKey)
const buyerNpub = buyerKeyPair.npub
// Counter offer wallet data
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1", false)
const counterOfferAddr = counterOfferWallet.walletInfo.cashAddress
return {
takerAddr: buyerAddr,
takerNpub: buyerNpub,
counterOfferAddr
}
} catch (error) {
console.error('Error getCounterOfferMetadata()', error)
throw error
}
}
// Get the counter offers for a given address
async getCounterOffersByAddress (addr) {
try {
const options = {
method: 'GET',
url: `${SERVER}offer/list/counter-offer/${addr}`
}
const result = await axios.request(options)
return result.data
} catch (error) {
console.error('Error getting counter offers by address', error)
throw error
}
}
}
function sleep (ms) {
+378
View File
@@ -0,0 +1,378 @@
/**
* Nostr class for query into relay pools
* Refactored to use REST API instead of WebSocket
*/
import NostrRestClient, { generateSubId } from './nostr-rest-client.js'
import * as nip19 from 'nostr-tools/nip19'
import { nip04 } from 'nostr-tools'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import axios from 'axios'
import config from '../config'
const SERVER = `${config.dexServer}/`
export default class NostrQueries {
constructor ({ relays }) {
// Keep relays property for backward compatibility (empty array)
this.relays = []
// Initialize REST client
this.restClient = new NostrRestClient()
this.loadedProfiles = {}
this.loadedChannelsInfo = {}
this.blackList = []
this.blackListFetched = false
this.deletedChats = []
this.deletedPosts = []
}
async start () {
try {
await this.getBlackList()
await this.fetchDeletedChats()
await this.fetchDeletedPosts()
} catch (error) {
console.error('NostrQueries.start() error : ', error.message)
throw error
}
}
setRelays (relays) {
// No-op for backward compatibility (REST API handles relays server-side)
this.relays = []
}
npubToHex (npub) {
const pubHexData = nip19.decode(npub)
const pubHex = pubHexData.data
return pubHex
}
hexToNpub (hex) {
return nip19.npubEncode(hex)
}
// Load profile from nostr relays via REST API
async getProfile (pubHex) {
try {
const existingProfile = this.loadedProfiles[pubHex]
if (existingProfile) {
console.log(`Returning profile from cache : ${existingProfile.name}`)
return existingProfile
}
const subId = generateSubId('profile')
const filter = { limit: 5, kinds: [0], authors: [pubHex] }
const events = await this.restClient.queryEvents(subId, filter)
// Find the most recent profile event (kind 0 events are replaceable)
if (events && events.length > 0) {
// Sort by created_at descending to get most recent
events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = events[0]
try {
const profile = JSON.parse(profileEvent.content)
console.log(`Profile found for ${pubHex}`)
this.loadedProfiles[pubHex] = profile
return profile
} catch (error) {
console.warn(`Error parsing profile content for ${pubHex}:`, error)
return false
}
}
return false
} catch (error) {
console.warn(`Error fetching profile for ${pubHex}:`, error)
return false
}
}
// Get Feeds by user pubkey via REST API
async getUserFeeds (pubHex) {
try {
const subId = generateSubId('user-feeds')
const filter = { limit: 5, kinds: [1], authors: [pubHex] }
let feeds = await this.restClient.queryEvents(subId, filter)
// 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 fetching user feeds for ${pubHex}:`, error)
return []
}
}
// Get global feeds via REST API
async getGlobalFeeds () {
try {
const subId = generateSubId('global-feeds')
const filter = { limit: 10, kinds: [1], '#t': ['slpdex-socialmedia'] }
let feeds = await this.restClient.queryEvents(subId, filter)
// Filter out deleted posts
feeds = feeds.filter((ev) => {
const isDeleted = this.deletedPosts.find((val) => { return val.eventId === ev.id })
return !isDeleted
})
// 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 fetching global feeds:', error)
return []
}
}
// Get follow list by pubkey via REST API
async getFollowList (pubHex) {
try {
const subId = generateSubId('follow-list')
const filter = { limit: 1, kinds: [3], authors: [pubHex] }
const events = await this.restClient.queryEvents(subId, filter)
// Get the most recent follow list (kind 3 events are replaceable)
if (events && events.length > 0) {
// Sort by created_at descending to get most recent
events.sort((a, b) => b.created_at - a.created_at)
const followListEvent = events[0]
return followListEvent.tags || []
}
return []
} catch (error) {
console.warn(`Error fetching follow list for ${pubHex}:`, error)
return []
}
}
// Get event likes via REST API
async getPostLikes (postId) {
try {
const subId = generateSubId('post-likes')
const filter = { kinds: [7], '#e': [postId] }
let likesRes = await this.restClient.queryEvents(subId, filter)
// 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 fetching post likes for ${postId}:`, error)
return []
}
}
async getChannelInfo (channelId) {
try {
const existingChInfo = this.loadedChannelsInfo[channelId]
if (existingChInfo) {
console.log(`Returning ch info from cache : ${existingChInfo.name}`)
return existingChInfo
}
const subId = generateSubId('channel-info')
const filter = { limit: 1, kinds: [41], '#e': [channelId] }
const events = await this.restClient.queryEvents(subId, filter)
if (events && events.length > 0) {
// Sort by created_at descending to get most recent
events.sort((a, b) => b.created_at - a.created_at)
const channelEvent = events[0]
try {
const chInfo = JSON.parse(channelEvent.content)
this.loadedChannelsInfo[channelId] = chInfo
return chInfo
} catch (error) {
console.warn(`Error parsing channel info for ${channelId}:`, error)
return false
}
}
return false
} catch (error) {
console.warn(`Error fetching channel info for ${channelId}:`, error)
return false
}
}
// Get associated pub keys from kind 04 inbox via REST API
async getDms (pubKey) {
try {
const subId = generateSubId('dms')
// Use array of filters for multiple conditions
const filters = [
{ limit: 100, kinds: [4], '#p': [pubKey] }, // received messages
{ limit: 100, kinds: [4], authors: [pubKey] } // sent messages
]
const events = await this.restClient.queryEvents(subId, filters)
const list = []
for (const ev of events) {
if (ev.pubkey === pubKey) {
// Sent message - get recipient from tags
if (ev.tags && ev.tags.length > 0 && ev.tags[0][0] === 'p') {
list.push(ev.tags[0][1])
}
} else {
// Received message - get sender pubkey
list.push(ev.pubkey)
}
}
// Remove duplicated pubkeys
const dms = list.filter((val, i, list) => {
const existingIndex = list.findIndex(value => value === val)
return existingIndex === i
})
return dms
} catch (error) {
console.warn(`Error fetching DMs for ${pubKey}:`, error)
return []
}
}
async encryptMsg (inObj = {}) {
try {
const { senderPrivKey, receiverPubKey, message } = inObj
const privateKeyBin = hexToBytes(senderPrivKey)
const encryptedMsg = await nip04.encrypt(privateKeyBin, receiverPubKey, message)
console.log('encryptedMsg', encryptedMsg)
return encryptedMsg
} catch (error) {
console.warn(error)
throw error
}
}
async decryptMsg (inObj = {}) {
try {
console.log(inObj)
const { receiverPrivKey, senderPubKey, encryptedMsg } = inObj
const privateKeyBin = hexToBytes(receiverPrivKey)
const decryptedMsg = await nip04.decrypt(privateKeyBin, senderPubKey, encryptedMsg)
console.log('decryptedMsg', decryptedMsg)
return decryptedMsg
} catch (error) {
console.warn(error)
throw error
}
}
async getBlackList () {
try {
if (this.blackListFetched) return
const opts = {
method: 'GET',
url: 'https://blacklists.psfoundation.info/nostr-chat-blacklist.json',
headers: {
'Content-Type': 'application/json'
}
}
const result = await axios.request(opts)
const { data } = result
const { npubs } = data
// // Dev test mock npub for testing purposes
// const myNpubMock = 'npub1y3xq402pu9aqms3khetnt5gzm54t63pzcla56wwfmwshde0ws77qkff5gc'
// npubs.push(myNpubMock)
// //
this.blackListFetched = true
for (let i = 0; i < npubs.length; i++) {
const npub = npubs[i]
const pubKey = this.npubToHex(npub)
this.blackList.push(pubKey)
}
console.log('BlackList ', this.blackList)
return data
} catch (error) {
console.log('Error on getBlackList()')
throw error
}
}
// Get all deleted chats
async fetchDeletedChats () {
try {
const options = {
method: 'GET',
url: `${SERVER}nostr/deletedChat`
}
const result = await axios.request(options)
const { deletedChats } = result.data
this.deletedChats = deletedChats
} catch (error) {
console.error('Error fetchDeletedChats: ', error)
throw error
}
}
// Get all deleted posts
async fetchDeletedPosts () {
try {
const options = {
method: 'GET',
url: `${SERVER}nostr/deletedPost`
}
const result = await axios.request(options)
console.log('result', result)
const { deletedPosts } = result.data
console.log('deletedPosts', deletedPosts)
this.deletedPosts = deletedPosts
} catch (error) {
console.error('Error fetchDeletedPosts: ', error)
throw error
}
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* REST API Client for Nostr relay interactions via REST2NOSTR proxy
*/
import config from '../config/index.js'
/**
* Generate a unique subscription ID
* @param {string} prefix - Prefix for the subscription ID
* @returns {string} Unique subscription ID
*/
export function generateSubId (prefix = 'sub') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
class NostrRestClient {
constructor (localConfig = {}) {
this.apiUrl = localConfig.apiUrl || config.nostrRestApiUrl
this.activeSubscriptions = new Map() // Track active SSE subscriptions
}
/**
* Publish a signed event to the relay
* @param {Object} signedEvent - Signed Nostr event
* @returns {Promise<Object>} Response with {accepted, message, eventId}
*/
async publishEvent (signedEvent) {
try {
const response = await fetch(`${this.apiUrl}/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedEvent)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const result = await response.json()
return result
} catch (error) {
console.error('Error publishing event:', error)
throw error
}
}
/**
* Query events statelessly (GET request)
* @param {string} subId - Subscription ID
* @param {Array|Object} filters - Filter object or array of filters
* @returns {Promise<Array>} Array of events
*/
async queryEvents (subId, filters) {
try {
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters]
// Encode filters as query parameter
const filtersJson = encodeURIComponent(JSON.stringify(filtersArray))
const url = `${this.apiUrl}/req/${subId}?filters=${filtersJson}`
const response = await fetch(url)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const events = await response.json()
return events
} catch (error) {
console.error('Error querying events:', error)
throw error
}
}
/**
* Create a subscription for Server-Sent Events (SSE)
* @param {string} subId - Subscription ID
* @param {Array|Object} filters - Filter object or array of filters
* @param {Object} callbacks - Callback functions {onEvent, onEose, onClosed, onError}
* @returns {EventSource} EventSource instance for cleanup
*/
createSubscription (subId, filters, callbacks = {}) {
const { onEvent, onEose, onClosed, onError } = callbacks
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters]
// Use POST method for SSE subscription
// Note: EventSource doesn't support POST, so we'll use fetch with streaming
// However, for simplicity and browser compatibility, we'll use a workaround:
// Create a form and use POST, or use EventSource with GET if the server supports it
// For now, we'll use fetch with streaming and parse SSE manually
// This is more complex but allows POST with filters in body
const abortController = new AbortController()
const fetchSubscription = async () => {
try {
const response = await fetch(`${this.apiUrl}/req/${subId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream'
},
body: JSON.stringify(filtersArray),
signal: abortController.signal
})
if (!response.ok) {
const errorText = await response.text()
if (onError) {
onError(new Error(`HTTP ${response.status}: ${errorText}`))
}
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6)) // Remove 'data: ' prefix
if (data.type === 'connected') {
// Connection established
console.log(`Subscription ${subId} connected`)
} else if (data.type === 'event' && data.data) {
// Event received
if (onEvent) {
onEvent(data.data)
}
} else if (data.type === 'eose') {
// End of stored events
if (onEose) {
onEose()
}
} else if (data.type === 'closed') {
// Subscription closed
if (onClosed) {
onClosed(data.message || 'Subscription closed')
}
return // Stop reading
}
} catch (parseError) {
console.warn('Error parsing SSE message:', parseError, line)
}
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
// Subscription was cancelled, this is expected
return
}
console.error('Error in SSE subscription:', error)
if (onError) {
onError(error)
}
}
}
// Store subscription for cleanup
const subscription = {
subId,
abortController,
close: () => {
abortController.abort()
this.closeSubscription(subId)
}
}
this.activeSubscriptions.set(subId, subscription)
// Start the subscription
fetchSubscription()
return subscription
}
/**
* Close an existing subscription
* @param {string} subId - Subscription ID to close
* @returns {Promise<void>}
*/
async closeSubscription (subId) {
try {
// Abort the fetch if it's still active
const subscription = this.activeSubscriptions.get(subId)
if (subscription && subscription.abortController) {
subscription.abortController.abort()
}
// Send DELETE request to close subscription
const response = await fetch(`${this.apiUrl}/req/${subId}`, {
method: 'DELETE'
})
if (!response.ok) {
const errorText = await response.text()
console.warn(`Error closing subscription ${subId}:`, errorText)
}
// Remove from active subscriptions
this.activeSubscriptions.delete(subId)
} catch (error) {
console.error(`Error closing subscription ${subId}:`, error)
// Still remove from active subscriptions even if DELETE fails
this.activeSubscriptions.delete(subId)
}
}
/**
* Close all active subscriptions
*/
async closeAllSubscriptions () {
const subIds = Array.from(this.activeSubscriptions.keys())
await Promise.all(subIds.map(subId => this.closeSubscription(subId)))
}
}
export default NostrRestClient
+115
View File
@@ -0,0 +1,115 @@
/*
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 BchNostr from 'bch-nostr'
import * as nip19 from 'nostr-tools/nip19'
import config from '../config/index.js'
import NostrRestClient from './nostr-rest-client.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
})
// Initialize REST client for publishing events
this.restClient = new NostrRestClient()
}
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 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
// Publish the message via REST API
const result = await this.restClient.publishEvent(signedEvent)
if (!result.accepted) {
throw new Error(`Failed to publish event: ${result.message || 'Unknown error'}`)
}
// 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