Compare commits

...
171 Commits
Author SHA1 Message Date
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
65 changed files with 17642 additions and 2251 deletions
File diff suppressed because it is too large Load Diff
+6676 -1881
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,14 +7,15 @@
"@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",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.0.2",
"bch-dex-lib": "2.2.0",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
"bootstrap": "5.2.0",
"crypto-browserify": "3.12.1",
"marked": "15.0.12",
"nostr-tools": "2.10.4",
"p2wdb": "2.4.1",
"process": "0.11.10",
@@ -22,7 +23,9 @@
"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",
+173
View File
@@ -41,6 +41,9 @@
width: auto!important;
}
.cursor-pointer {
cursor: pointer;
}
/* Remove input number arrows Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
@@ -52,4 +55,174 @@ input::-webkit-inner-spin-button {
/* 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;
}
}
+88 -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,14 +137,6 @@ 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)
@@ -93,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}`,
@@ -112,7 +189,7 @@ function App (props) {
}
}
asyncEffect()
}, [appData, addToModal])
}, [appData, addToModal, isSignleView, backgroundAsync])
return (
<>
@@ -3,18 +3,39 @@
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
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 bchjs = appData.wallet.bchjs
const sats = appData.bchWalletState.bchBalance
const bchBalance = bchjs.BitcoinCash.toBitcoinCash(sats)
const usdBalance = bchjs.Util.floor2(bchBalance * appData.bchWalletState.bchUsdPrice)
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 (
<>
@@ -25,25 +46,37 @@ const BalanceCard = (props) => {
</Card.Title>
<br />
<Container>
<Row>
<Col>
<b>USD</b>: ${usdBalance}
</Col>
</Row>
{bchInitLoaded && (
<Container>
<Row>
<Col>
<b>USD</b>: ${usdBalance}
</Col>
</Row>
<Row>
<Col>
<b>BCH</b>: {bchBalance}
</Col>
</Row>
<Row>
<Col>
<b>BCH</b>: {bchBalance}
</Col>
</Row>
<Row>
<Col>
<b>Satoshis</b>: {sats}
</Col>
</Row>
</Container>
<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>
</>
@@ -50,6 +50,9 @@ export default function RefreshBchBalance (props) {
// 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()
@@ -16,6 +16,10 @@ 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()
@@ -28,7 +32,7 @@ function RefreshBchBalanceButton (props) {
return (
<>
<Button variant='success' onClick={() => { handleButtonRefreshBalance(appData) }}>
<Button variant='success' onClick={() => { handleButtonRefreshBalance(appData) }} disabled={!backgroundDataLoaded}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
@@ -15,6 +15,7 @@ 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([])
@@ -29,6 +30,9 @@ function SendCard (props) {
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()
@@ -318,7 +322,7 @@ function SendCard (props) {
<Row>
<Col style={{ textAlign: 'center' }}>
<Button onClick={(e) => handleSendBch({ sendCardData, appData })}>Send</Button>
<Button onClick={(e) => handleSendBch({ sendCardData, appData })} disabled={!backgroundDataLoaded}>Send</Button>
</Col>
</Row>
@@ -165,28 +165,28 @@ function WalletSummary (props) {
</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.privHex}</span>
<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.privHex)}
onClick={() => toggleNostrPrivateKeyBlur(nostrKeyPair.nsec)}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
<CopyOnClick appData={appData} value={nostrKeyPair.privHex} />
<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.pubHex}
<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.pubHex} />
<CopyOnClick appData={appData} value={nostrKeyPair.npub} />
</Col>
</Row>
</Container>
@@ -6,6 +6,7 @@
// 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
@@ -13,6 +14,7 @@ function ConfigurationView (props) {
return (
<>
<ServerSelectView appData={appData} />
<RelaySelectionView appData={appData} />
</>
)
}
@@ -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
+16 -8
View File
@@ -15,17 +15,21 @@ 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 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 ServerSelectView from './configuration/select-server-view'
import NostrPost from './nostr/nostr-post.js'
import NostrRead from './nostr/nostr-read.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 Offers from './offers'
import NostrChat from './nostr-chat'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -44,10 +48,14 @@ function AppBody (props) {
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
<Route path='/sweep' element={<SweepWif appData={appData} />} />
<Route path='/sign' element={<SignMessage appData={appData} />} />
<Route path='/configuration' element={<ServerSelectView appData={appData} />} />
<Route path='/configuration' element={<ConfigurationView appData={appData} />} />
<Route path='/nostr-post' element={<NostrPost appData={appData} />} />
<Route path='/nostr-read' element={<NostrRead appData={appData} />} />
<Route path='/profile/:npub' element={<Profile appData={appData} />} />
<Route path='/feeds' element={<Feeds appData={appData} />} />
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
{/* {appData.currentPath !== '/servers' && <SelectServerButton linkTo='/servers' appData={appData} />} */}
@@ -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
+267 -27
View File
@@ -3,15 +3,15 @@
workflow:
1. Load offers
2. display token cards with current offers data
2. Display token cards with current Offer data
3. Load tokens data
4. add data to offers and update card with new 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
6. Add icons to offers and update card with new icons
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
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'
@@ -20,6 +20,8 @@ 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
@@ -32,11 +34,26 @@ function NftsForSale (props) {
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')
// Handler for refresh button
const handleRefresh = () => {
console.log('handling refresh')
loadNftOffers()
// 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
@@ -57,6 +74,20 @@ function NftsForSale (props) {
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) => {
@@ -64,15 +95,17 @@ function NftsForSale (props) {
setOffersAreLoaded(false)
const result = await axios.get(`${SERVER}/offer/list/nft/${page}`)
const rawOffers = result.data
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)
processedOffers.push(processedOffer)
const processedOfferMetadata = await processOfferMetadata(processedOffer)
processedOffers.push(processedOfferMetadata)
}
setOffersAreLoaded(true)
@@ -83,7 +116,7 @@ function NftsForSale (props) {
setOffersAreLoaded(true)
throw err
}
}, [processTokenData])
}, [processTokenData, processOfferMetadata])
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
@@ -98,7 +131,7 @@ function NftsForSale (props) {
// 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
@@ -115,7 +148,7 @@ function NftsForSale (props) {
}, [appData])
// Fetch mutable data if it exist and get the token icon url
const fetchTokenInfo = useCallback(async (token) => {
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
@@ -135,15 +168,16 @@ function NftsForSale (props) {
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return iconUrl
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokens) => {
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
@@ -151,14 +185,16 @@ function NftsForSale (props) {
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 = await fetchTokenInfo(thisToken)
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.
@@ -169,33 +205,92 @@ function NftsForSale (props) {
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenInfo])
}, [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 () => {
const loadNftOffers = useCallback(async (page = 0) => {
try {
setIsLoading(true)
// Get tokens in offers
const offers = await getNftOffers()
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
// Load tokens data for any updates to the token data
await lazyLoadTokenData(offers)
// Load tokens icons
await lazyLoadTokenIcons(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, lazyLoadTokenIcons, getNftOffers])
}, [lazyLoadTokenData, lazyLoadMutableData, getNftOffers, reviewNftCachedData, updateNFTCachedData])
// Effect to load NFTs on component mount
useEffect(() => {
console.log('loading nfts for sale')
loadNftOffers()
// 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
@@ -206,8 +301,34 @@ function NftsForSale (props) {
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.
function generateCards (offers) {
const generateCards = useCallback(() => {
console.log('generateCards() offerData: ', offers)
const tokens = offers
@@ -223,13 +344,14 @@ function NftsForSale (props) {
token={thisToken}
handleRefresh={handleRefresh}
key={`${thisToken.tokenId + i}`}
updateOffer={updateOffer}
/>
)
tokenCards.push(thisTokenCard)
}
return tokenCards
}
}, [offers, appData, handleRefresh, updateOffer])
return (
<Container>
@@ -237,6 +359,9 @@ function NftsForSale (props) {
<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' }}>
@@ -270,6 +395,121 @@ function NftsForSale (props) {
</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' }} />
@@ -6,11 +6,19 @@
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
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()')
@@ -22,6 +30,48 @@ function InfoButton (props) {
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) {
@@ -70,9 +120,28 @@ function InfoButton (props) {
<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 />
<Modal.Footer style={{ justifyContent: 'center' }}>
{!loading && <Button style={{ width: '135px' }} onClick={updateOffer}>Update</Button>}
{loading && <Spinner />}
</Modal.Footer>
</Modal>
</>
)
@@ -0,0 +1,184 @@
/*
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 = () => {
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
@@ -10,16 +10,15 @@ 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 } = props
const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
console.log('TokenCard() appData: ', appData)
// Update icon state every token.icon and token.tokenData changes
useEffect(() => {
console.log('setting icon')
setIcon(token.icon)
setTokenData(token.tokenData)
}, [token.icon, token.tokenData])
@@ -27,8 +26,14 @@ function TokenCard (props) {
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
<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
@@ -49,7 +54,6 @@ function TokenCard (props) {
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
@@ -64,16 +68,16 @@ function TokenCard (props) {
</Row>
<br />
<Row>
<Row className='text-center'>
<Col>
<InfoButton token={token} disabled={!token.tokenData} />
<InfoButton token={token} disabled={!token.tokenData} {...props} />
</Col>
<Col />
<Col>
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
</Col>
{!hideBuyBtn && (
<Col>
<BuyButton token={token} disabled={!token.tokenData} appData={appData} onSuccess={handleRefresh} />
</Col>
)}
</Row>
</Container>
@@ -83,17 +87,5 @@ function TokenCard (props) {
</>
)
}
// eslint-disable-next-line
{/* <Col>
<InfoButton token={props.token} />
</Col>
<Col>
<FlagButton appData={props.appData} offer={props.token} />
</Col>
<Col>
<BuyNftButton appData={props.appData} offer={props.token} />
</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
@@ -1,5 +1,5 @@
/*
Component for posting nostr information.
Component for posting nostr information on kind 1.
*/
// Global npm libraries
@@ -7,16 +7,19 @@ import React, { useState } from 'react'
import { Container, Form, Button, Spinner } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from '@noble/hashes/utils' // already an installed dependency
import NostrRestClient from '../../../../services/nostr-rest-client.js'
function NostrPost (props) {
const [accordionKey, setAccordionKey] = useState('1')
// 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({
name: '',
about: ''
content: ''
})
const [errorMsg, setErrorMsg] = useState('')
@@ -45,17 +48,15 @@ function NostrPost (props) {
// Convert private key to binary
const privateKeyBin = hexToBytes(nostrKeyPair.privHex)
// Relay list
const psf = 'wss://nostr-relay.psfoundation.info'
const formDataString = JSON.stringify(formData)
// BCH address of the user.
const bchAddr = bchWalletState.address
// Generate a post.
const eventTemplate = {
kind: 0,
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: formDataString
tags: [['t', 'slpdex-socialmedia'], ['u', bchAddr]],
content: formData.content
}
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
@@ -63,16 +64,14 @@ function NostrPost (props) {
const signedEvent = finalizeEvent(eventTemplate, privateKeyBin)
console.log('signedEvent: ', signedEvent)
// Connect to a relay.
const relay = await Relay.connect(psf)
console.log(`connected to ${relay.url}`)
// Publish the message to the relay.
const result = await relay.publish(signedEvent)
// Publish the post via REST API (handles broadcasting to multiple relays)
const result = await restClient.publishEvent(signedEvent)
console.log('result: ', result)
// Close the connection to the relay.
relay.close()
if (!result.accepted) {
throw new Error(`Failed to publish post: ${result.message || 'Unknown error'}`)
}
resetForm()
setSuccessMsg('Post successfully published!')
setOnFetch(false)
@@ -89,17 +88,16 @@ function NostrPost (props) {
const resetForm = () => {
setFormData({
name: '',
about: ''
content: ''
})
}
return (
<>
<Container>
<Container className='mt-4'>
<Accordion activeKey={accordionKey} onSelect={handleAccordionChange}>
<Accordion.Item eventKey='1'>
<Accordion.Header>Post</Accordion.Header>
<Accordion.Item eventKey='0'>
<Accordion.Header>Public Post</Accordion.Header>
<Accordion.Body>
{errorMsg && (
<div className='alert alert-danger' role='alert'>
@@ -113,25 +111,13 @@ function NostrPost (props) {
)}
<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.Label>Message</Form.Label>
<Form.Control
as='textarea'
rows={3}
placeholder='Tell us about yourself'
name='about'
value={formData.about}
rows={7}
placeholder="What's happening?"
name='content'
value={formData.content}
onChange={handleInputChange}
required
/>
@@ -155,4 +141,4 @@ function NostrPost (props) {
)
}
export default NostrPost
export default PublicPost
@@ -1,67 +0,0 @@
// Global npm libraries
import React, { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import Accordion from 'react-bootstrap/Accordion'
import { RelayPool } from 'nostr'
function NostrRead (props) {
const { bchWalletState } = props.appData
const [posts, setPosts] = useState([])
const [accordionKey, setAccordionKey] = useState('0')
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// Get Last post from a author
const start = () => {
const { nostrKeyPair } = bchWalletState
const psf = 'wss://nostr-relay.psfoundation.info'
const pool = RelayPool([psf])
pool.on('open', relay => {
relay.subscribe('subid', { limit: 10, kinds: [0], authors: [nostrKeyPair.pubHex] })
})
pool.on('eose', relay => {
console.log('Closing Relay')
relay.close()
})
pool.on('event', (relay, subId, ev) => {
console.log('Received event:', ev)
setPosts(currentPosts => [...currentPosts, ev])
})
setLoaded(true)
}
if (!loaded) {
start()
}
}, [bchWalletState, loaded])
const handleAccordionChange = (key) => {
setAccordionKey(key)
}
return (
<Container>
<Accordion activeKey={accordionKey} onSelect={handleAccordionChange}>
<Accordion.Item eventKey='0'>
<Accordion.Header>Read ( {posts.length} post found )</Accordion.Header>
<Accordion.Body>
{posts.map((post, index) => (
<div key={index}>
<span>{post.content}</span>
</div>
))}
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Container>
)
}
export default NostrRead
@@ -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
+231
View File
@@ -0,0 +1,231 @@
/*
This React components downloads the active Offers from the REST API and
displays them in a data table.
*/
// Global npm libraries
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Table, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import { DatatableWrapper, TableBody, TableHeader } from 'react-bs-datatable'
// Local libraries
import config from '../../../config'
import WaitingModal from '../../waiting-modal'
// Global variables and constants
const SERVER = `${config.dexServer}/`
const TABLE_HEADERS = [
{
prop: 'ticker',
title: 'Ticker',
isFilterable: true
},
{
prop: 'tokenId',
title: 'Token ID'
},
{
prop: 'buyOrSell',
title: 'Type'
},
{
prop: 'smallNostrEventId',
title: 'Event ID'
},
{
prop: 'numTokens',
title: 'Quantity'
},
{
prop: 'usdPrice',
title: 'Price (USD)'
},
{
prop: 'button',
title: 'Action'
}
]
function Offers (props) {
const [appData] = useState(props.appData)
const [offers, setOffers] = useState([])
// Modal state
const [showModal, setShowModal] = useState(false)
const [modalBody, setModalBody] = useState([])
const [hideSpinner, setHideSpinner] = useState(false)
const [denyClose, setDenyClose] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Given a large string, it will return a string with the first and last
// four characters.
const cutString = (str) => {
try {
const subTxid = str.slice(0, 4)
const subTxid2 = str.slice(-4)
return `${subTxid}...${subTxid2}`
} catch (err) {
console.warn('Error in cutString() ', err)
}
}
// REST request to get data from avax-dex
const getOffers = async () => {
try {
const options = {
method: 'GET',
url: `${SERVER}offer/list/fungible/0`,
data: {}
}
const result = await axios.request(options)
return result.data
} catch (err) {
console.warn('Error in getOffers() ', err)
}
}
const handleBuy = useCallback(async (event) => {
try {
console.log('Buy button clicked. Event: ', event)
const targetOfferEventId = event.target.id
console.log('targetOfferEventId: ', targetOfferEventId)
// Initialize modal
setShowModal(true)
setModalBody(['Generating Counter Offer...', '(This can take a couple minutes)'])
setHideSpinner(false)
setDenyClose(true)
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
offerData,
partialHex
})
console.log('eventId: ', eventId)
console.log('noteId: ', noteId)
// update output modal
const newModalBody = []
newModalBody.push('Success!')
newModalBody.push('What happens next:')
newModalBody.push('The money has not yet left your wallet! It is still under your control.')
newModalBody.push('If the sellers node is online, they will accept the Counter Offer you just generated in a few minutes.')
newModalBody.push('If the tokens never show up, you can sweep the funds back into your wallet.')
setModalBody(newModalBody)
setHideSpinner(true)
setDenyClose(false)
} catch (error) {
console.warn('Error in handleBuy() ', error)
setModalBody(['Buy failed: ', error.message])
setHideSpinner(true)
setDenyClose(false)
}
}, [appData]) // Dependencies: appData since it's used inside the callback
// Get Offer data and manipulate it for the sake of presentation.
const handleOffers = useCallback(async () => {
setIsLoading(true)
// Get raw offer data.
const offerRawData = await getOffers()
console.log('offerRawData: ', offerRawData)
if (!offerRawData || offerRawData.length === 0) {
setIsLoading(false)
setOffers([])
return
}
// Formatted Data
const formattedOffers = []
for (let i = 0; i < offerRawData.length; i++) {
const thisOffer = offerRawData[i]
// Get and format the token ID
const tokenId = thisOffer.tokenId
const smallTokenId = cutString(tokenId)
thisOffer.tokenId = (<a href={`https://token.fullstack.cash/?tokenid=${tokenId}`} target='_blank' rel='noreferrer'>{smallTokenId}</a>)
// Get and format the P2WDB ID
const nostrEventId = thisOffer.nostrEventId
const smallNostrEventId = cutString(nostrEventId)
thisOffer.smallNostrEventId = smallNostrEventId
thisOffer.button = (<Button text='Buy' variant='success' size='lg' id={nostrEventId} onClick={handleBuy}>Buy</Button>)
// thisOffer.p2wdbHash = (<a href={`https://p2wdb.fullstack.cash/entry/hash/${p2wdbHash}`} target='_blank' rel='noreferrer'>{smallP2wdbHash}</a>)
// Convert sats to BCH, and then calculate cost in USD.
const bchjs = appData.wallet.bchjs
const rateInSats = parseInt(thisOffer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice
const priceStr = `$${usdPrice.toFixed(3)}`
thisOffer.usdPrice = priceStr
formattedOffers.push(thisOffer)
}
setOffers(formattedOffers)
setIsLoading(false)
}, [appData, handleBuy])
useEffect(() => {
// Retrieve initial offer data
handleOffers()
// Get data and update the table periodically.
const interval = setInterval(() => {
handleOffers()
}, 30000)
// Cleanup interval on component unmount
return () => clearInterval(interval)
}, [handleOffers]) // Empty dependency array means this effect runs once on mount
const heading = 'Generating Counter Offer...'
return (
<>
{showModal && (
<WaitingModal
heading={heading}
body={modalBody}
hideSpinner={hideSpinner}
denyClose={denyClose}
/>
)}
<Container>
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
{!isLoading && (
<DatatableWrapper body={offers} headers={TABLE_HEADERS}>
<Table>
<TableHeader />
<TableBody />
</Table>
</DatatableWrapper>
)}
</Col>
</Row>
{isLoading && (
<Row>
<Col className='text-break' style={{ textAlign: 'center' }}>
<Spinner animation='border' />
</Col>
</Row>
)}
</Container>
</>
)
}
export default Offers
+2 -2
View File
@@ -14,8 +14,8 @@ function SignMessage (props) {
const [sign, setSign] = useState('')
const [msg, setMsg] = useState('')
const [bchAddr] = useState(wallet.walletInfo.cashAddress)
const [slpAddr] = useState(wallet.walletInfo.slpAddress)
const [bchAddr] = useState(wallet?.walletInfo?.cashAddress)
const [slpAddr] = useState(wallet?.walletInfo?.slpAddress)
const [err, setErr] = useState('')
const [copied, setCopied] = useState(false)
+100 -31
View File
@@ -13,11 +13,17 @@ import TokenCard from './token-card'
import RefreshTokenBalance from './refresh-tokens'
const SlpTokens = (props) => {
const [appData, setAppData] = useState(props.appData)
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(() => {
@@ -30,8 +36,7 @@ const SlpTokens = (props) => {
// within the wallet app.
// This function triggers the on-click function within the refresh-tokens.js button.
const refreshTokens = async () => {
const newAppData = await refreshTokenButtonRef.current.handleRefreshTokenBalance()
setAppData(newAppData)
await refreshTokenButtonRef.current.handleRefreshTokenBalance()
}
// Get Cid from url
@@ -44,37 +49,68 @@ const SlpTokens = (props) => {
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 fetchTokenIcon = useCallback(async (token) => {
const fetchTokenMutableData = useCallback(async (token) => {
try {
// Get the token data
const tokenData = await appData.wallet.getTokenData(token.tokenId)
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
const iconUrl = json.tokenIcon
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
const userData = json.userData
// Return icon url
return iconUrl
return { iconUrl, userData }
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async () => {
const lazyLoadMutableData = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
const tokens = appData.bchWalletState.slpTokens
setTokens(tokens) // update token state
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
@@ -83,28 +119,38 @@ const SlpTokens = (props) => {
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenIcon(thisToken)
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
}
appData.updateBchWalletState({ walletObj: { slpTokens: tokens }, appData })
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [appData, fetchTokenIcon])
}, [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(() => {
lazyLoadTokenIcons()
}, [lazyLoadTokenIcons])
if (slpInitLoaded) {
loadData()
}
}, [loadData, slpInitLoaded])
// Generate the token cards for each token in the wallet.
const generateCards = () => {
@@ -127,7 +173,7 @@ const SlpTokens = (props) => {
<RefreshTokenBalance
appData={appData}
ref={refreshTokenButtonRef}
lazyLoadTokenIcons={lazyLoadTokenIcons}
lazyLoadTokenIcons={loadData}
/>
</Col>
<Col xs={6} style={{ textAlign: 'right' }}>
@@ -137,17 +183,35 @@ const SlpTokens = (props) => {
</Col>
</Row>
<Row>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{
!iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Token Icons </span>
<Spinner animation='border' />
</div>
)
}
{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>
</Col>
)}
</Row>
<br />
@@ -155,11 +219,16 @@ const SlpTokens = (props) => {
{generateCards()}
</Row>
{/** Display a message if no tokens are found */}
{tokens.length === 0 && (
{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>
</>
@@ -6,18 +6,18 @@
*/
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col } from 'react-bootstrap'
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')) {
if (url?.includes('http')) {
url = (<a href={url} target='_blank' rel='noreferrer'>{url}</a>)
//
} else if (url.includes('ipfs://')) {
} else if (url?.includes('ipfs://')) {
// Convert to a Filecoin link if its an IPFS reference.
const cid = url.substring(7)
@@ -29,6 +29,7 @@ function linkIfUrl (url) {
function InfoButton (props) {
const [show, setShow] = useState(false)
const [mutableDataCid, setMutableDataCid] = useState(null)
const handleClose = () => {
setShow(false)
@@ -45,6 +46,33 @@ function InfoButton (props) {
// 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>
@@ -87,6 +115,25 @@ function InfoButton (props) {
<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 />
@@ -18,6 +18,10 @@ function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons
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) => {
@@ -58,6 +62,10 @@ function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons
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)
@@ -82,7 +90,7 @@ function RefreshTokenBalance ({ appData: initialAppData, ref, lazyLoadTokenIcons
return (
<>
<Button variant='success' onClick={handleRefreshTokenBalance}>
<Button variant='success' onClick={handleRefreshTokenBalance} disabled={!backgroundDataLoaded}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
@@ -12,7 +12,7 @@ import InfoButton from './info-button'
import SendTokenButton from './send-token-button'
function TokenCard (props) {
const { token } = props
const { token, hideSendBtn } = props
const [icon, setIcon] = useState(token.icon)
// Update icon state every token.icon changes
@@ -64,13 +64,15 @@ function TokenCard (props) {
<Col>
<InfoButton token={props.token} />
</Col>
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
{!hideSendBtn && (
<Col>
<SendTokenButton
token={props.token}
appData={props.appData}
refreshTokens={props.refreshTokens}
/>
</Col>
)}
</Row>
</Container>
</Card.Body>
+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
+117 -78
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,97 +24,137 @@ 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' />{' '}
DEX
</Navbar.Brand>
<Navbar.Toggle aria-controls='responsive-navbar-nav' />
<Navbar.Collapse id='responsive-navbar-nav'>
<Nav className='mr-auto'>
{!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>
<NavLink
className={(currentPath === '/nfts-for-sale' || currentPath === '/') ? 'nav-link-active' : 'nav-link-inactive'}
to='/nfts-for-sale'
onClick={handleClickEvent}
>
NFTs for Sale
</NavLink>
<NavLink
className={currentPath === '/offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/offers'
onClick={handleClickEvent}
>
Fungible Tokens
</NavLink>
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<hr />
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</NavLink>
<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>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
<hr />
>
Wallet
</NavLink>
<NavLink
className={(currentPath === '/bch') ? 'nav-link-active' : 'nav-link-inactive'}
to='/bch'
onClick={handleClickEvent}
>
BCH
</NavLink>
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
onClick={handleClickEvent}
>
Tokens
</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>
<NavLink
className={currentPath === '/nostr-post' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-post'
onClick={handleClickEvent}
>
Nostr Post
</NavLink>
<NavLink
className={currentPath === '/nostr-read' ? 'nav-link-active' : 'nav-link-inactive'}
to='/nostr-read'
onClick={handleClickEvent}
>
Nostr Profile
</NavLink>
</Nav>
</Navbar.Collapse>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
onClick={handleClickEvent}
>
Wallet
</NavLink>
<NavLink
className={(currentPath === '/balance') ? 'nav-link-active' : 'nav-link-inactive'}
to='/balance'
onClick={handleClickEvent}
>
Check Balance
</NavLink>
<NavLink
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sweep'
onClick={handleClickEvent}
>
Sweep
</NavLink>
<NavLink
className={(currentPath === '/sign') ? 'nav-link-active' : 'nav-link-inactive'}
to='/sign'
onClick={handleClickEvent}
>
Sign
</NavLink>
<NavLink
className={currentPath === '/configuration' ? 'nav-link-active' : 'nav-link-inactive'}
to='/configuration'
onClick={handleClickEvent}
>
Configuration
</NavLink>
</Nav>
</Navbar.Collapse>
)}
</Navbar>
</>
)
+1 -1
View File
@@ -21,7 +21,7 @@ export function UninitializedView (props = {}) {
/>
{
appData.asyncInitFinished
? <AppBody menuState={100} wallet={appData.wallet} appData={appData} />
? <> <br /><AppBody menuState={100} wallet={appData.wallet} appData={appData} /></>
: null
}
</>
+16 -1
View File
@@ -15,9 +15,24 @@ const config = {
radicleUrl: 'https://app.radicle.network/seeds/maple.radicle.garden/rad:git:hnrkd5cjwwb5tzx37hq9uqm5ubon7ee468xcy/remotes/hyyycncbn9qzqmobnhjq9rry6t4mbjiadzjoyhaknzxjcz3cxkpfpc',
dexServer: 'https://dex-api.fullstack.cash',
// dexServer: 'http://localhost:5700',
nostrTopic: 'bch-dex-test-topic-02',
nostrRelay: 'wss://nostr-relay.psfoundation.info'
// 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'
]
}
+120 -9
View File
@@ -1,23 +1,31 @@
import { useState } from 'react'
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: {
serverUrl: 'https://free-bch.fullstack.cash' // Default server
}
defaultValue: localStorageDefault
})
console.log('lsState: ', lsState)
// Initialize data states
const [serverUrl, setServerUrl] = useState(lsState.serverUrl) // Default server url
const [menuState, setMenuState] = useState(0)
@@ -25,6 +33,7 @@ function useAppState () {
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)
@@ -36,6 +45,26 @@ 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.
@@ -49,7 +78,7 @@ function useAppState () {
// 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)
@@ -89,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,
@@ -131,7 +225,24 @@ function useAppState () {
dexLib,
setDexLib,
nostr,
setNostr
setNostr,
nftForSaleCacheData,
setNftForSaleCacheData,
updateNFTCachedData,
lastFeedTab,
setLastFeedTab,
isSingleView,
setIsSingleView,
nostrQueries: nostrQueriesRef.current,
relaysData,
updateRelaysData,
restoreRelaysData,
readRelays,
writeRelays,
startChannelChat,
setStartChannelChat,
asyncBackGroundInitState,
updateBackGroundInitState
}
}
+61 -4
View File
@@ -13,6 +13,7 @@ import P2WDB from 'p2wdb'
import { base58_to_binary as base58ToBinary } from 'base58-js'
import { bytesToHex } from '@noble/hashes/utils' // already an installed dependency
import { getPublicKey } from 'nostr-tools/pure'
import * as nip19 from 'nostr-tools/nip19'
class AsyncLoad {
constructor () {
@@ -65,7 +66,7 @@ class AsyncLoad {
// 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.
@@ -89,6 +90,11 @@ 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 })
@@ -124,6 +130,10 @@ 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)
@@ -265,15 +275,62 @@ class AsyncLoad {
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)
console.log('BCH & Nostr private key (HEX format): ', privHex)
// 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
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
}
}
}
+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
+9 -13
View File
@@ -10,10 +10,10 @@
// import * as nip19 from '@chris.troutner/nostr-tools/nip19'
import { finalizeEvent } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay'
import BchNostr from 'bch-nostr'
import * as nip19 from 'nostr-tools/nip19'
import config from '../config/index.js'
import NostrRestClient from './nostr-rest-client.js'
class NostrBrowser {
constructor (localConfig = {}) {
@@ -27,6 +27,9 @@ class NostrBrowser {
relayWs: config.nostrRelay,
topic: config.nostrTopic
})
// Initialize REST client for publishing events
this.restClient = new NostrRestClient()
}
async testNostrUpload (inObj = {}) {
@@ -69,7 +72,6 @@ class NostrBrowser {
// tags: [['t', 'bch-dex-test-topic-01']]
// }
const relayWs = config.nostrRelay
const eventTemplate = {
kind: 867,
created_at: Math.floor(Date.now() / 1000),
@@ -82,18 +84,12 @@ class NostrBrowser {
// console.log('signedEvent: ', signedEvent)
const eventId = signedEvent.id
// Connect to a relay.
const relay = await Relay.connect(relayWs, {
/* global WebSocket */
webSocket: WebSocket
})
// console.log(`connected to ${relay.url}`)
// Publish the message via REST API
const result = await this.restClient.publishEvent(signedEvent)
// Publish the message to the relay.
await relay.publish(signedEvent)
// Close the connection to the relay.
relay.close()
if (!result.accepted) {
throw new Error(`Failed to publish event: ${result.message || 'Unknown error'}`)
}
// const eventId = await this.bchNostr.post.uploadToNostr(inObj)