first commit
8
=2.0.0
Normal file
@ -0,0 +1,8 @@
|
||||
yarn add v1.22.22
|
||||
[1/4] Resolving packages...
|
||||
[2/4] Fetching packages...
|
||||
[3/4] Linking dependencies...
|
||||
[4/4] Building fresh packages...
|
||||
success Saved lockfile.
|
||||
success Saved 0 new dependencies.
|
||||
Done in 1.63s.
|
691
CHANGELOG.md
Normal file
@ -0,0 +1,691 @@
|
||||
# nostrudel
|
||||
|
||||
## 0.40.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2d74bc7: Add "mark read" button to notifications view
|
||||
- 43d02ee: Fix nostr-relay-tray connection issues
|
||||
|
||||
## 0.40.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a38efa5: Add support for NIP-49 (ncryptsec)
|
||||
- 085e12a: Display NIP-89 client tags on events
|
||||
- 03bec50: Add wiki pages
|
||||
- f53f5ca: Add support for wiki links in text notes
|
||||
- f53f5ca: Add simple article view
|
||||
- d03b329: Show read status on notifications
|
||||
- 5add281: Add blindspots discovery feed
|
||||
- f9ba9cb: Remove "Setup Relays" overlay when starting app
|
||||
- 4c3d041: Show individual zaps on notes
|
||||
- 1eb6c49: Show notifications on launchpad
|
||||
- c648923: Add Streams and Tools to launchpad
|
||||
- 4c3d041: Add details tabs under thread post
|
||||
- f9443af: Make user avatars square
|
||||
- d20f698: Add Multi-threaded PoW Hashing thanks to [Thoreau](https://github.com/thoreaufyi)
|
||||
- 6862854: Add option to hide emojis in usernames
|
||||
- 8bb7fc1: Rebuilt settings view tabs
|
||||
- 9deb032: Add option to hide zap bubbles on notes
|
||||
- 8d46272: Add blossom media upload option
|
||||
- 92b950a: Add support for native android and ios sharing
|
||||
- c137b3d: Add support for NIP-51 search relay list
|
||||
- 423632f: Add option to prune older events in wasm relay
|
||||
- 781948a: Use Relay class from nostr-tools
|
||||
- b4c4c7a: Show relay authentication requests
|
||||
- 91c9ad1: Fallback to users blossom servers on broken image links with sha256
|
||||
- 7a486bb: Add menu to zap events
|
||||
- 958a850: Add option to use nostr-wasm to verify events
|
||||
- 24c664e: Add NIP-46 connection initiated by client
|
||||
- fa6bc0e: Make "Show embeds" option work again
|
||||
- 8a24016: Add No cache relay option
|
||||
- 8a24016: Add In-Memory cache relay option
|
||||
- 8defd66: Add support for @snort/worker-relay as a cache relay
|
||||
- 289fff2: Remove CORS_PROXY env option in docker image
|
||||
- 8faf3e4: Add task manager modal for debugging
|
||||
- 7506141: Show timelines, subscriptions, and services in task manager
|
||||
- 289fff2: Add REQUEST_PROXY, TOR_PROXY, and I2P_PROXY env options in docker image
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3da4c3f: Support embedding media from IPFS
|
||||
- 81aefc5: Fix null relay hints in DMs
|
||||
- 5c49114: Fix users own events being hidden by muted words
|
||||
- 51c8aff: Fix random events showing up as DM messages
|
||||
- f36a82a: Fix app prompting NIP-07 extension to unlock when app opens
|
||||
- fbcfa42: Remove corsproxy.io as default service for CORS proxy
|
||||
|
||||
## 0.39.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 15cb30d: Add "open in" modal (NIP-89)
|
||||
- 16ae69c: Add event publisher tool
|
||||
- b88ecd2: Added Event Console tool
|
||||
- e053e5d: Add option to automatically decrypt DMs
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- df094b2: Rebuild observable class
|
||||
- 3359064: Add UI tab to relays
|
||||
- a967cc8: Fix custom emoji reactions having multiple colons
|
||||
- cfa0461: Fix jsonl database export format
|
||||
- 45e447c: Fix auto-playing blurred videos
|
||||
- 3a8bea9: Fix bunker://pubkey connect URIs
|
||||
- 0c36f57: Fix profile form removing unknown metadata fields
|
||||
- 45e447c: Unblur all images when clicking on a note
|
||||
- bcb3ff8: Update emojilib
|
||||
|
||||
## 0.38.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 64c2bb3: Fix translation selector stuck on english
|
||||
|
||||
## 0.38.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add option to use NIP-65 relays on relay prompt
|
||||
|
||||
## 0.38.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ad6e51e: Add explanations to relay views
|
||||
|
||||
## 0.38.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Always use the bitcoin connect webln
|
||||
|
||||
## 0.38.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e8e3dc0: Support for nsecBunker OAuth flow
|
||||
- 31a649e: Add offline mode
|
||||
- 3bae870: Restore scroll position when returning to the timeline
|
||||
- fd6ce3e: Show unavailable events in threads
|
||||
- bbf5b0e: Add POW option when writing note
|
||||
- 91f4c7c: Overhaul core relay code
|
||||
- aaa6208: Support kind 16 generic reposts
|
||||
- f965281: Support using nostr-relay-tray as cache relay
|
||||
- 5831791: Rebuild tools menu
|
||||
- 7640beb: Improve display of unknown events
|
||||
- d1af1e1: Add track view for stemstr tracks
|
||||
- 92fe0bb: Support for local image proxy and cors servers
|
||||
- 1731b66: Show Videos and articles on bookmark list
|
||||
- 9fa2ae4: Add threads notifications view
|
||||
- 33ff50f: Support for bunker://npub@relay NIP-46 login
|
||||
- 05b4ca2: Add search when selecting list in feed
|
||||
- be49839: Improve channel message layout
|
||||
- a39e6ad: Add NIP-66 relay stats service
|
||||
- 1191d99: Add NIP definitions when hovering over "NIP-xx"
|
||||
- c744751: Add messages to launchpad
|
||||
- 075fb4e: Add simple bookmarks view
|
||||
- 1888caa: Build simple flare video page
|
||||
- d9225ed: Add support for .mp3 and .wav urls
|
||||
- ad53ed1: Add Simple Satellite CDN view
|
||||
- c3bcfe4: Remove ackee
|
||||
- 1f77a48: Add CACHE_RELAY option to docker container
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 065a90f: Show quotes as mentions in notifications
|
||||
- 4fb0faa: count nevent and naddr as pubkey mentions
|
||||
- 5831791: Show NIP-05 verified icons in @ mentions
|
||||
- 0972691: Fix issue with search relays getting reset
|
||||
- c744751: Fix bug with stuck timelines
|
||||
- 3204258: Upgrade nostr-tools to v2
|
||||
|
||||
## 0.37.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feec6880: Fix storage and clipboard use on http connection
|
||||
|
||||
## 0.37.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 53b2c9e3: Add reactions and zaps to DMs
|
||||
- 98b4bef4: Add support for threads in DMs
|
||||
- 43faa025: Add support for Amber signer
|
||||
- 53b2c9e3: Make DMs view more readable
|
||||
- 53b2c9e3: Add support for NIP-46 signer
|
||||
- ca4d6df8: Support NIP-31 on unknown event kinds
|
||||
|
||||
## 0.36.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bc71d920: Add option to hide usernames
|
||||
- abce505a: Add Torrent create view
|
||||
- 2786f848: Add support for default bookmark list
|
||||
- c119e02a: Add decrypt all button to DMs
|
||||
- abce505a: Change "Copy Share Link" to use njump.me
|
||||
- abce505a: Replace "Copy Note Id" with "Copy Embed Code"
|
||||
- 6ab2d1c2: Add colors to notifications view
|
||||
- a2a920c4: Add simple torrents view
|
||||
- 7ff3c81d: Add Channels view
|
||||
- a714a2c6: Use nevent instead of note1 in urls
|
||||
- 199f208b: Add local relay cache option
|
||||
- d8e08d6a: Add support for Nostr Signing Device
|
||||
- 6d44e534: Rebuild notifications view
|
||||
- c8ee526a: Rebuild tools view
|
||||
- b372edab: Show reposts in note details modal
|
||||
- c119e02a: Cache decrypted events
|
||||
- a796661e: Add comments to torrents
|
||||
- a714a2c6: Blur videos from strangers
|
||||
- d18e03af: Rebuild thread loading
|
||||
- b69bfa37: Show list links on muted by view
|
||||
|
||||
## 0.35.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7cbffb96: Add option to pin notes
|
||||
- 7cbffb96: Show pinned notes on user profile
|
||||
|
||||
## 0.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 32c3e74a: Add note translations modal using DVMs
|
||||
- e144f13e: Improve how reposts and replies are displayed in timelines
|
||||
- 90700ebb: Use kind 10004 for communities list instead of kind 30001
|
||||
- d19b0001: Show SoundCloud embeds
|
||||
|
||||
## 0.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5e9afb0d: Add "DM Feed" tool
|
||||
- cc4247dc: Thread view improvements
|
||||
- 6d701a7b: Add option to search communities in search view
|
||||
- 5e9afb0d: Add "create $prism" link to lists
|
||||
- 35bb0e37: Add people lists to search and hashtag views
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d1181ef9: Fix link cards breaking lines
|
||||
|
||||
## 0.32.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5c036ff: Fix error when clearing database cache
|
||||
- 5c036ff: Fix scrolling in direct messages view
|
||||
- 02b8374: Fix community join button "no account" error
|
||||
|
||||
## 0.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0414039: Show users joined communities on about page
|
||||
- 5d66750: Add vote buttons to community view
|
||||
- 5f9c96e: Add approval button for pending community posts
|
||||
- 0d00f71: Add network dm graph tool
|
||||
- d7e289a: Show community members
|
||||
- 5d66750: Improve community view on mobile
|
||||
- 8871aed: Add support for kind 6 events in communities
|
||||
- 1f73120: Add option to search notes in search view
|
||||
- 28de4d4: Add community create and edit modals
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5ac4cfc: Fix hashtags and links with (non-english) mark characters in them
|
||||
- 7b03925: Improve drawer navigation
|
||||
- a11d448: Center layout
|
||||
- 1c8f005: Fix reaction counts when user react multiple times
|
||||
- 35236c6: Add "show more" button when viewing all reactions
|
||||
|
||||
## 0.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9569281: Add option to customize quick reactions
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4c0d10f: Fix people list selection
|
||||
- 08eb8b2: Fix zap button on prism posts
|
||||
|
||||
## 0.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f701942: Add redeem button for cashu tokens
|
||||
- 7a3674f: Add option to set zap splits when creating note
|
||||
- 85a9dad: Add stemstr embeds
|
||||
- bcc8427: Add theme option and better dark theme
|
||||
- 5a455c7: Show repost counts on notes
|
||||
- c0e3269: Add support for paying zap splits
|
||||
- cdfdc71: Show recent badge awards on badges page
|
||||
- d2f3076: Make notes clickable
|
||||
- 4efbc48: Add sign up view
|
||||
- 21a1a8a: Show profile badges on users profile
|
||||
- b2be294: Support video and audio file uploads to nostr.build
|
||||
- 2a17d9e: Show articles in lists
|
||||
- d9353b0: Update all icons
|
||||
- 56fc982: Add badge activity tab
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c635b2b: Fix bug with stream chat not showing on chromium based browsers
|
||||
- 37489e5: Reduce churn when loading relays on app load
|
||||
|
||||
## 0.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9fd16ea: Add time durations for muting users
|
||||
- cff1e8b: Add simple stream moderation tool
|
||||
- a1a0e33: Add popular relays view
|
||||
- 02ea06a: Add nostr.build image uploads
|
||||
- 0f87642: Add simple community views
|
||||
- d0e58de: Add image upload button to reply form
|
||||
- 9fd16ea: Add ghost mode
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 20fb8fb: Fix freezing when navigating back to main timeline
|
||||
- 4ce9897: Fix broken links in side drawer
|
||||
- 4ce9897: Fix bug when clicking on shared long form note
|
||||
|
||||
## 0.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6021318: Add community browse view
|
||||
- e04aa5c: Hide muted users in stream views
|
||||
- e04aa5c: Add option to add user to k 10000 mute list
|
||||
- b961ee1: Add side drawer for viewing threads
|
||||
- f440e81: Redesign side navigation
|
||||
- 269acae: Add Max Page width option to display settings
|
||||
- e04aa5c: Filter out muted users in home feed
|
||||
- dfce72d: Clean up embedded note component
|
||||
- e04aa5c: Hide muted users in threads
|
||||
- 4b5445a: Add tabs to notification view
|
||||
- cde3174: Add Muted words option in display settings
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 03ed574: Small fix for url RegExp
|
||||
- 054e3f2: Show multiple pubkeys on badge award event
|
||||
- d5a50d0: Fix follow and mute button not updating when switching accounts
|
||||
|
||||
## 0.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 94cd156: Add share button to stream view
|
||||
- 03fb661: Sort search results by follower count
|
||||
- cbb3aa5: Show embedded badges in stream cards and timelines
|
||||
- 3d5d234: Clean up user reactions view
|
||||
- 6b4fd8a: Show list embeds in notes
|
||||
- b561568: Rebuild notifications view
|
||||
- 409f219: Add content warning switch when writing note
|
||||
- 076b89e: Add articles tab to user view
|
||||
- 094a6fb: Show stream goal zaps in stream chat
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 81e86c9: Fix threads not loading when navigating directly to them
|
||||
|
||||
## 0.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8fd08ed: Add reply button to note feed
|
||||
- 1b5ee34: Add emoji edit view
|
||||
- 7a5a4b1: Add emoji pack views
|
||||
- 2a490dd: Add goal views
|
||||
- 27abb20: Show host emojis when writing stream chat message
|
||||
- 3a2745e: Add @ user autocomplete when writing notes
|
||||
- 2a490dd: Improve event embed card
|
||||
- c10a17e: Add emoji autocomplete when writing notes
|
||||
- 6dd6196: Rebuild stream view layout
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8bf5d82: Optimize caching time for user metadata events
|
||||
|
||||
## 0.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f83d1ad: Show streamer cards in stream view on desktop
|
||||
- c79c292: Show emoji reactions on notes
|
||||
- 0af6c2c: Add bookmark button to notes
|
||||
- 8ea8c88: Add more details to publish details modal
|
||||
- d53a34c: Add browse lists view
|
||||
- 343a23c: Add sats per minute button on stream view on desktop
|
||||
- 6bb4589: Add option to favorite lists
|
||||
- 8ea8c88: Filter relay reviews by list
|
||||
- f6f4656: Allow user to select people list for home feed
|
||||
- 0af6c2c: Show note lists on lists view
|
||||
- 63474a7: Add delete button for lists
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 954ec50: Fix reactions showing on wrong notes
|
||||
- fbc1ea4: Fix mentioning npub would freeze app
|
||||
|
||||
## 0.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 03d84eb: Show notes in relay view
|
||||
- 1e75dbd: Improve layout of image galleries
|
||||
- 07f67cc: Show all images in lightbox
|
||||
- d2948e7: Rebuild event publish details
|
||||
- 1148093: Render multiple images as image gallery
|
||||
- d8b29b4: Add relay review form
|
||||
- 9b6c653: Add simple timeline health view
|
||||
- b7deb16: Clean up navigation menu
|
||||
- 018c917: Add mobile friendly lightbox
|
||||
- ce550f5: Show label for paid relays
|
||||
- e052991: Add inline reply form
|
||||
- 70bada5: Add <url> and <encoded_url> options to CORS proxy url
|
||||
- 70bada5: Use corsproxy.io as default service for CORS proxy
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1bc4500: Fix non-english characters breaking links
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e24e55c: Show relay reviews under user relays tab
|
||||
- fa30250: Add relay view
|
||||
- e24e55c: Add relay reviews page
|
||||
- d984577: Show relay recommendations in timeline
|
||||
- 33da3e2: Rebuild relay view and show relay reviews
|
||||
- 615e19b: Hide muted users in stream chat
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cb780e1: Cleanup responsive breakpoints
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c7d9a04: Rebuild search view to use NIP-50
|
||||
- 69bea82: Add support for playing back stream recordings
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 69bea82: Correctly handle replaceable events in timeline loader
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 980c68a: Show lightning address on about page
|
||||
- 68001bb: Add people list context and selector
|
||||
- 640edef: Use timeline loader for followers view
|
||||
- 5c061ca: Add expiration to cached metadata events
|
||||
- 5c061ca: Rebuild underlying event requester classes
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 85dd32a: Add logging to app setting services
|
||||
- 85dd32a: Fix Color Mode setting
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 52d567c: Cleanup embed content (hopefully performance improvement)
|
||||
- 7cc9c9a: cache url open graph data
|
||||
- 52d567c: Remove twitter tweet embeds
|
||||
- 1afbe85: Add docker image
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b8a3fd1: small fix for hashtags
|
||||
- 7cc9c9a: Performance improvements
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- af5ed2f: Fix broken post button
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f786056: Replace nostrchat clink with blowater
|
||||
- 0074c9e: Remove scroll-boxes and return to natural page scrolling
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d46327e: Support hashtags in new post modal
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d46327e: Fix bug with non-english hashtags not showing
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b32b6be: Image gallery: Only show open button on over
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5d4a680: Fix stream view crashing when failing to parse zap request
|
||||
- dd4cb0b: Fix npub in url getting replaced in post modal
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d4a8110: Standardize timeline rendering between views
|
||||
- facb287: Add more prominent new post button
|
||||
- bdc1c98: Rebuild direct message chat view using timeline loader
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d4a8110: Fix performance bug with large timelines
|
||||
- bdc1c98: Don't show multiple images on open-graph link card
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e4b40dd: Blur images in stream chat
|
||||
- 33acce5: UX improvements to zap modal
|
||||
- 5a537ab: Add toggle chat button to mobile stream view
|
||||
- 086279e: Add user likes tab under profile view
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 33acce5: Fixed bug with stream loading wrong chat
|
||||
- 871d699: Fix blured images opening when clicked
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0c92da8: Add views for watching streams
|
||||
- 7a339ae: cache timelines
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 593ad6b: show type of account on account picker
|
||||
- 038d342: truncate open graph card description
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c036a9a: Fix all pop-in issues when loading timelines (rebuild timeline loader to use IntersectionObserver to correctly set cursor)
|
||||
- b23fe91: Rebuild timeline loader class
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b23fe91: Remove broken discover tab
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4bdae99: Only fetch open graph metadata for html urls
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 644c53e: Handle hashtags in search view
|
||||
- 0cc4059: Fetch open graph metadata for links
|
||||
- 2eeb79c: Display custom emojis
|
||||
- 214487e: Add relay icons to notes
|
||||
- f383903: replace momentjs with dayjs
|
||||
- 5d19861: Add multi relay selection to hashtag view
|
||||
- 39ef920: Improve editing and saving app settings
|
||||
- 0cc4059: Add CORS proxy
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9936c25: Add validation check to LNURL address in profile edit view
|
||||
- 7f162ac: Fix user app settings being cached
|
||||
- 17d5160: Use nevent when quote reposting note
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a6a1ca3: Create about tab in profile view
|
||||
- a6a1ca3: Virtulize following and followers tabs in profile view
|
||||
- a6a1ca3: Add profile stats from nostr.band
|
||||
- 9464e3a: Add settings for Invidious, Nitter, Libreddit, Teddit redirects
|
||||
- 305f5e2: Add link to nostr army knife tool
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 65bd2e9: Only show kind 0 events in media tab
|
||||
- 305f5e2: Correctly handle web+nostr: links in search
|
||||
- a6a1ca3: Fix redirect not working on login view
|
||||
- 3939f66: Correctly handle quote notes with nostr: links and e tags
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ddcafeb: Add nostrapp.link option in profile and note menus
|
||||
- ddcafeb: add embeds for wavlake tracks
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 868227a: Add e2e tests
|
||||
- 2aa6ec5: Dont require login for profile and note views
|
||||
- d58ef29: Add simple nip19 tool
|
||||
- 7e92cba: Add media tab in profile view
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d4aef8f: cleanup fetching user relays
|
||||
- 0189507: Dont add p tag when sharing events
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c21a662: Make all note links nevent
|
||||
- 40c5e19: Update nostr-tools dependency
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5e7f6b0: Dont proxy main user profile image
|
||||
- 2d2e233: Dont blur images on shared notes
|
||||
- f432cf6: Trim note content
|
||||
- 40c5e19: Fix link regexp
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4dfb277: Make photo flush with edge of note
|
||||
- 285a2dd: Add content warning for NIP-36 notes
|
||||
- 4dfb277: Replace laggy photo lightbox
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0df1db8: Fix subscription id too long
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 80eda91: Add support for nprofile and nevent types in paths
|
||||
- 3ae7fe6: Show "Follow Back" on follow button if user is following you
|
||||
- 444ba5f: Add external link for mostr notes
|
||||
- 10dc835: Add option to load image thumbnails with imageproxy service
|
||||
- ac1c9cb: Add simple hashtag view
|
||||
- 10dc835: Add image lightbox and zoom
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b75b1b3: Show image and video embeds in DMs (big refactor to support hashtags)
|
||||
- 096bc06: Desktop: Remove following list on right side
|
||||
- 5cfdd90: Add simple event deletion modal
|
||||
- 096bc06: Mobile: Move user icon to bottom bar
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3f4477a: Confirm before reposting
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 66c6b4d: Add option to change primary color for theme
|
||||
- a209b9d: Improve database migration
|
||||
- a209b9d: Store app settings in NIP-78 Arbitrary app data event with local fallback
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e75ac1b: Add support for kind 6 reposts
|
||||
- b9b8179: Add copy button to user QrCode modal
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d4321c0: Remove brb.io link from user profiles
|
||||
- de46005: Add custom zap amounts to settings
|
||||
- 52033fc: Support nostr: links in notes
|
||||
- c6a4f96: Add "Download Backup" link to profile edit view
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 06f8e59: Increase min height of note before showing expandable overlay
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7aec637: Add github link to settings view
|
||||
- 1f40f56: Add lighting payment mode setting
|
28
CONTRIBUTING.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for considering contributing to the project! I welcome contributions from everyone.
|
||||
|
||||
**NOTE: If you are looking to open a PR, please fork off of the `next` branch since that has the latest changes**
|
||||
|
||||
## How to contribute
|
||||
|
||||
- Fork the repo
|
||||
- Checkout the `next` branch `git checkout next`
|
||||
- Create a new branch for your feature `git checkout -b new-feature`
|
||||
- Start making changes and fixing things
|
||||
- Open a pull request [here](https://github.com/hzrd149/nostrudel/pulls)
|
||||
- Wait for feedback. it might take me a while to look at it
|
||||
|
||||
## Guidelines
|
||||
|
||||
- All code if formatted with prettier. you can run `yarn run format` to format the project
|
||||
- Write clear and concise commit messages
|
||||
|
||||
## Issues and Bug Reports
|
||||
|
||||
If you find a bug or have an idea for a new feature, please create an issue in our repository.
|
||||
Be sure to include a clear description of the problem or feature request, and what version you found the bug in. (either `nostrudel.ninja` or `next.nostrudel.ninja`)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the project's [license](./LICENSE).
|
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023 hzrd149
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
4
build.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
/usr/bin/git pull
|
||||
/usr/bin/yarn install --production=false --froze-lockfile
|
||||
/usr/bin/yarn build
|
36
docker-compose.yaml
Normal file
@ -0,0 +1,36 @@
|
||||
version: "3.7"
|
||||
|
||||
volumes:
|
||||
data: {}
|
||||
|
||||
services:
|
||||
i2p-proxy:
|
||||
image: purplei2p/i2pd:release-2.51.0
|
||||
tor-proxy:
|
||||
image: dockage/tor-privoxy:latest
|
||||
cors:
|
||||
image: ghcr.io/hzrd149/docker-cors-anywhere:0.4.5
|
||||
environment:
|
||||
CORSANYWHERE_REQUIRE_HEADERS: "host"
|
||||
imageproxy:
|
||||
image: ghcr.io/willnorris/imageproxy:v0.11.2
|
||||
relay:
|
||||
image: scsibug/nostr-rs-relay:0.8.13
|
||||
volumes:
|
||||
- data:/usr/src/app/db
|
||||
app:
|
||||
build: .
|
||||
image: ghcr.io/hzrd149/nostrudel:latest
|
||||
depends_on:
|
||||
- relay
|
||||
- tor-proxy
|
||||
- i2p-proxy
|
||||
- imageproxy
|
||||
environment:
|
||||
CACHE_RELAY: relay:8080
|
||||
IMAGE_PROXY: imageproxy:8080
|
||||
TOR_PROXY: tor-proxy:9050
|
||||
I2P_PROXY: i2p-proxy:4444
|
||||
REQUEST_PROXY: cors:8080
|
||||
ports:
|
||||
- 8080:80
|
136
docker-entrypoint.sh
Executable file
@ -0,0 +1,136 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PROXY_PASS_BLOCK=""
|
||||
|
||||
# start tor if set to true
|
||||
if [ "$TOR_PROXY" = "true" ]; then
|
||||
echo "Starting tor socks proxy"
|
||||
|
||||
tor &
|
||||
tor_process=$!
|
||||
TOR_PROXY="127.0.0.1:9050"
|
||||
fi
|
||||
|
||||
# inject request proxy
|
||||
if [ -n "$REQUEST_PROXY" ]; then
|
||||
REQUEST_PROXY_URL="$REQUEST_PROXY"
|
||||
|
||||
echo "Request proxy set to $REQUEST_PROXY"
|
||||
sed -i 's/REQUEST_PROXY = ""/REQUEST_PROXY = "\/request-proxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /request-proxy/ {
|
||||
proxy_pass http://$REQUEST_PROXY_URL;
|
||||
rewrite ^/request-proxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
|
||||
if [ -n "$PROXY_FIRST" ]; then
|
||||
echo "Telling app to use request proxy first"
|
||||
sed -i 's/PROXY_FIRST = false/PROXY_FIRST = true/g' /usr/share/nginx/html/index.html
|
||||
fi
|
||||
else
|
||||
echo "No request proxy set"
|
||||
fi
|
||||
|
||||
# inject cache relay URL
|
||||
if [ -n "$CACHE_RELAY" ]; then
|
||||
echo "Cache relay set to $CACHE_RELAY"
|
||||
sed -i 's/CACHE_RELAY_ENABLED = false/CACHE_RELAY_ENABLED = true/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /local-relay {
|
||||
proxy_pass http://$CACHE_RELAY/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No cache relay set"
|
||||
fi
|
||||
|
||||
# inject image proxy URL
|
||||
if [ -n "$IMAGE_PROXY" ]; then
|
||||
echo "Image proxy set to $IMAGE_PROXY"
|
||||
sed -i 's/IMAGE_PROXY_PATH = ""/IMAGE_PROXY_PATH = "\/imageproxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /imageproxy/ {
|
||||
proxy_pass http://$IMAGE_PROXY;
|
||||
rewrite ^/imageproxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No Image proxy set"
|
||||
fi
|
||||
|
||||
CONF_FILE="/etc/nginx/conf.d/default.conf"
|
||||
NGINX_CONF="
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name localhost;
|
||||
merge_slashes off;
|
||||
|
||||
$PROXY_PASS_BLOCK
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# Gzip settings
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
"
|
||||
echo "$NGINX_CONF" > $CONF_FILE
|
||||
|
||||
_term() {
|
||||
echo "Caught SIGTERM signal!"
|
||||
|
||||
# stop tor if started
|
||||
if [ "$TOR_PROXY" = "true" ]; then
|
||||
kill -SIGTERM "$tor_process" 2>/dev/null
|
||||
fi
|
||||
|
||||
# stop nginx
|
||||
kill -SIGTERM "$nginx_process" 2>/dev/null
|
||||
}
|
||||
|
||||
nginx -g 'daemon off;' &
|
||||
nginx_process=$!
|
||||
|
||||
trap _term SIGTERM
|
||||
|
||||
wait $nginx_process
|
28
dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY ./package*.json .
|
||||
COPY ./yarn.lock .
|
||||
ENV NODE_ENV='development'
|
||||
RUN yarn install --production=false --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV VITE_COMMIT_HASH=""
|
||||
ENV VITE_APP_VERSION="custom"
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:stable-alpine-slim AS main
|
||||
EXPOSE 80
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# setup entrypoint
|
||||
ADD ./docker-entrypoint.sh docker-entrypoint.sh
|
||||
RUN chmod a+x docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
35
index.html
Normal file
@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, viewport-fit=cover" />
|
||||
<link rel="stylesheet" href="./src/styles.css" />
|
||||
<title>Poster.place</title>
|
||||
<meta name="description" content="A simple nostr web client focused on exploring nostr" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" href="/logo.svg" sizes="any" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#8DB600" />
|
||||
|
||||
<meta property="og:url" content="https://poster.place" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Poster.Place" />
|
||||
<meta property="og:description" content="A simple nostr web client focused on exploring nostr" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://repository-images.githubusercontent.com/581644549/d5eec580-ba3d-41e8-87db-58c313bf3f45"
|
||||
/>
|
||||
|
||||
<script>
|
||||
window.CACHE_RELAY_ENABLED = false;
|
||||
window.IMAGE_PROXY_PATH = "";
|
||||
window.REQUEST_PROXY = "";
|
||||
window.PROXY_FIRST = false;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
7
maintainers.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
identifier: noStrudel
|
||||
maintainers:
|
||||
- npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr
|
||||
relays:
|
||||
- wss://nostrue.com/
|
||||
- wss://nostr.wine/
|
||||
- wss://nos.lol/
|
135
package.json
Normal file
@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "nostrudel",
|
||||
"version": "0.40.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hzrd149/nostrudel"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
"dev": "VITE_APP_VERSION=production vite serve",
|
||||
"build": "tsc --project tsconfig.json && vite build",
|
||||
"format": "prettier --ignore-path .prettierignore -w .",
|
||||
"analyze": "npx vite-bundle-visualizer -o ./stats.html",
|
||||
"build-icons": "node ./scripts/build-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^0.9.0",
|
||||
"@chakra-ui/anatomy": "^2.2.2",
|
||||
"@chakra-ui/breakpoint-utils": "^2.0.8",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/media-query": "^3.3.0",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/shared-utils": "^2.0.4",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@codemirror/autocomplete": "^6.17.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/language": "^6.10.2",
|
||||
"@codemirror/view": "^6.28.6",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect": "^3.4.1",
|
||||
"@getalby/bitcoin-connect-react": "^3.4.1",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@scure/base": "^1.1.6",
|
||||
"@snort/worker-relay": "^1.1.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.0",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"bech32": "^2.0.0",
|
||||
"blossom-client-sdk": "^0.7.0",
|
||||
"blossom-drive-sdk": "^0.4.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"chroma-js": "^2.4.2",
|
||||
"codemirror-json-schema": "^0.6.1",
|
||||
"dayjs": "^1.11.9",
|
||||
"debug": "^4.3.4",
|
||||
"easymde": "^2.18.0",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"emojilib": "^3",
|
||||
"framer-motion": "^10.16.0",
|
||||
"hls.js": "^1.4.14",
|
||||
"idb": "^8.0.0",
|
||||
"identicon.js": "^2.3.3",
|
||||
"iso-language-codes": "^2.0.0",
|
||||
"json-stringify-deterministic": "^1.0.12",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.locatecontrol": "^0.79.0",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^5.0.4",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-idb": "^2.1.6",
|
||||
"nostr-tools": "^2.7.1",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-force-graph-2d": "^1.25.4",
|
||||
"react-force-graph-3d": "^1.24.2",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-mosaic-component": "^6.1.0",
|
||||
"react-photo-album": "^2.3.0",
|
||||
"react-qr-barcode-scanner": "^1.0.6",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-wiki-link": "^2.0.1",
|
||||
"three": "^0.160.0",
|
||||
"three-spritetext": "^1.8.2",
|
||||
"three-stdlib": "^2.29.11",
|
||||
"webln": "^0.3.2",
|
||||
"workbox-core": "7.0.0",
|
||||
"workbox-precaching": "7.0.0",
|
||||
"workbox-routing": "7.0.0",
|
||||
"yet-another-react-lightbox": "^3.17.3",
|
||||
"zen-observable": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.1",
|
||||
"@types/chroma-js": "^2.4.3",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/dom-serial": "^1.0.6",
|
||||
"@types/identicon.js": "^2.3.4",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/leaflet.locatecontrol": "^0.74.4",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||
"@types/zen-observable": "^0.8.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"camelcase": "^8.0.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.2.10",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"workbox-build": "^7.0.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
}
|
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/icon-192-maskable.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
public/icon-512-maskable.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
public/icon-512.png
Normal file
After Width: | Height: | Size: 56 KiB |
220
public/logo.svg
Normal file
@ -0,0 +1,220 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg18"
|
||||
width="1000"
|
||||
height="1000"
|
||||
viewBox="0 0 999.99999 1000"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs22">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath34">
|
||||
<path
|
||||
d="M 0,3236.692 H 3834.782 V 0 H 0 Z"
|
||||
id="path32" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
id="g26"
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,4315.5867)">
|
||||
<rect
|
||||
style="fill:#8db600;fill-opacity:1;stroke-width:0;stroke-linecap:round"
|
||||
id="rect1"
|
||||
width="750"
|
||||
height="750"
|
||||
x="1.3125001e-05"
|
||||
y="-3236.6902"
|
||||
transform="scale(1,-1)"
|
||||
ry="375"
|
||||
rx="375" />
|
||||
<g
|
||||
id="g28"
|
||||
transform="matrix(0.14066448,0,0,0.14066448,105.49554,2638.9686)">
|
||||
<g
|
||||
id="g30"
|
||||
clip-path="url(#clipPath34)">
|
||||
<g
|
||||
id="g36"
|
||||
transform="translate(151.1082,1255.2644)">
|
||||
<path
|
||||
d="m 0,0 383.651,-118.015 c 58.837,40.336 152.415,104.657 191.943,132.798 155.817,110.95 356.643,140.171 565.44,82.044 C 1242.702,68.565 1351.981,26.975 1457.753,-33.701 1310.3,415.161 952.387,602.78 673.221,680.393 480.729,733.844 296.507,711.339 154.562,616.772 12.774,522.331 -75.876,362.857 -101.786,155.613 -110.464,86.25 -67.653,20.81 0,0"
|
||||
style="fill:#dd8577;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path38" />
|
||||
</g>
|
||||
<g
|
||||
id="g40"
|
||||
transform="translate(1243.6663,1734.3396)">
|
||||
<path
|
||||
d="m 0,0 c 63.09,-84.965 117.079,-184.946 156.076,-303.659 -105.772,60.676 -215.051,102.266 -316.719,130.528 -208.798,58.127 -409.623,28.906 -565.44,-82.044 -39.524,-28.141 -133.102,-92.462 -191.943,-132.798 l -273.054,83.994 c -0.965,-6.589 -2.428,-12.792 -3.264,-19.483 -8.678,-69.363 34.133,-134.804 101.786,-155.613 l 383.651,-118.015 c 58.837,40.336 152.415,104.656 191.943,132.798 155.817,110.95 356.643,140.171 565.44,82.044 101.668,-28.262 210.947,-69.853 316.719,-130.528 C 287.679,-276.804 151.831,-113.546 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path42" />
|
||||
</g>
|
||||
<g
|
||||
id="g44"
|
||||
transform="translate(1270.5486,2305.8904)">
|
||||
<path
|
||||
d="m 0,0 c 11.707,34.838 57.233,53.859 132.937,51.473 68.218,-1.035 153.494,-17.639 240.105,-46.734 161.796,-54.33 320,-153.071 294.498,-228.994 -11.346,-33.772 -54.911,-51.537 -126.173,-51.537 -2.229,0 -4.473,0 -6.764,0.063 -68.218,1.036 -153.495,17.639 -240.105,46.734 C 132.701,-174.665 -25.501,-75.924 0,0 m 504.315,292.677 c 11.692,34.839 56.998,54.11 132.937,51.474 68.218,-1.036 153.495,-17.639 240.105,-46.734 161.797,-54.33 320,-153.072 294.498,-228.995 -11.346,-33.772 -54.91,-51.536 -126.173,-51.536 -2.228,0 -4.473,0 -6.764,0.062 C 970.701,17.984 885.423,34.587 798.813,63.683 637.016,118.012 478.814,216.754 504.315,292.677 m 502.135,289.79 c 11.691,34.839 57.405,54.141 132.936,51.473 68.218,-1.035 153.495,-17.639 240.106,-46.733 161.796,-54.331 319.999,-153.072 294.497,-228.995 -11.346,-33.772 -54.942,-51.568 -126.22,-51.568 -2.213,0 -4.441,0.031 -6.701,0.063 -68.234,1.036 -153.51,17.639 -240.121,46.734 -161.796,54.361 -319.999,153.102 -294.497,229.026 M -437.62,-339.287 c 294.169,-81.784 674.006,-282.022 818.124,-770.325 74.974,-47.242 147.304,-104.857 213.003,-174.912 432.229,247.791 1450.433,832.059 1852.068,1067.048 50.375,29.472 77.414,85.497 68.877,142.745 -89.435,600.389 -503.75,836.382 -835.551,928.658 -175.622,48.743 -345.265,34.933 -477.699,-38.981 -431.61,-241.01 -1560.183,-895.715 -1975.758,-1137.138 104.905,20.82 219.517,15.546 336.936,-17.095"
|
||||
style="fill:#f4da8f;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path46" />
|
||||
</g>
|
||||
<g
|
||||
id="g48"
|
||||
transform="translate(1575.2781,2107.3401)">
|
||||
<path
|
||||
d="m 0,0 c 83.566,-28.059 165.375,-44.066 230.375,-45.071 66.901,-0.533 96.921,14.658 101.959,29.598 12.131,36.094 -86.344,125.168 -274.253,188.318 -83.567,28.059 -165.375,44.066 -230.376,45.071 -2.119,0.031 -4.19,0.031 -6.246,0.031 -63.086,0 -90.848,-15.129 -95.712,-29.629 C -286.384,152.224 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path50" />
|
||||
</g>
|
||||
<g
|
||||
id="g52"
|
||||
transform="translate(2581.7278,2689.7761)">
|
||||
<path
|
||||
d="m 0,0 c 83.55,-28.059 165.358,-44.066 230.359,-45.039 67.418,-0.722 96.953,14.626 101.975,29.597 12.131,36.094 -86.344,125.169 -274.254,188.318 -83.565,28.06 -165.374,44.066 -230.375,45.071 -2.119,0.031 -4.19,0.031 -6.246,0.031 -63.087,0 -90.848,-15.128 -95.713,-29.629 C -286.385,152.255 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path54" />
|
||||
</g>
|
||||
<g
|
||||
id="g56"
|
||||
transform="translate(2079.594,2400.0178)">
|
||||
<path
|
||||
d="m 0,0 c 83.565,-28.06 165.374,-44.066 230.375,-45.071 66.821,-0.816 96.921,14.626 101.959,29.597 12.131,36.095 -86.344,125.169 -274.254,188.318 -83.566,28.06 -165.375,44.067 -230.376,45.071 -2.118,0.031 -4.189,0.031 -6.245,0.031 -63.087,0 -90.848,-15.128 -95.713,-29.628 C -286.385,152.224 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path58" />
|
||||
</g>
|
||||
<g
|
||||
id="g60"
|
||||
transform="translate(1846.2991,992.655)">
|
||||
<path
|
||||
d="m 0,0 c -0.071,0.126 -0.063,0.269 -0.133,0.396 -66.132,71.751 -139.481,130.152 -215.499,177.8 -0.502,0.253 -0.941,0.561 -1.412,0.866 -114.36,71.446 -234.699,118.561 -345.713,149.427 -199.35,55.397 -390.43,27.965 -538.197,-77.273 -20.511,-14.605 -55.557,-38.939 -92.723,-64.589 115.91,31.651 245.543,29.815 378.762,-7.223 339.082,-94.253 763.112,-335.111 857.585,-947.176 4.99,-32.279 1.239,-64.323 -9.949,-93.478 63.576,43.289 147.77,101.106 185.257,129.007 41.619,30.947 62.192,84.461 53.67,139.638 C 231.635,-333.37 130.731,-142.65 2.327,-2.316 1.585,-1.489 0.573,-1.004 0,0"
|
||||
style="fill:#f4da8f;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path62" />
|
||||
</g>
|
||||
<g
|
||||
id="g64"
|
||||
transform="translate(1857.2058,219.9871)">
|
||||
<path
|
||||
d="m 0,0 c -91.868,595.115 -504.472,829.383 -834.421,921.125 -68.359,18.989 -135.667,28.405 -200.417,28.405 -116.569,0 -224.75,-30.593 -315.782,-90.532 -2.688,-1.84 -4.641,-3.172 -4.798,-3.282 0,-0.002 -0.01,-0.002 -0.01,-0.004 -140.36,-94.644 -228.241,-253.33 -254.002,-459.366 -8.678,-69.332 34.133,-134.804 101.786,-155.645 l 1319.779,-405.919 c 48.366,-14.877 100.029,-4.049 138.163,28.906 C -10.624,-102.508 7.957,-51.568 0,0"
|
||||
style="fill:#dd8577;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path66" />
|
||||
</g>
|
||||
<g
|
||||
id="g68"
|
||||
transform="translate(1857.2058,219.9871)">
|
||||
<path
|
||||
d="m 0,0 c -74.837,484.785 -362.504,730.083 -644.958,853.979 107.872,-134.752 191.257,-310.541 226.728,-540.302 7.956,-51.569 -10.625,-102.508 -49.701,-136.312 -38.134,-32.955 -89.796,-43.783 -138.162,-28.906 l -993.676,305.62 c -3.617,-18.93 -7.179,-37.91 -9.659,-57.733 -8.678,-69.332 34.133,-134.804 101.786,-155.645 l 1319.779,-405.919 c 48.366,-14.877 100.029,-4.049 138.163,28.906 C -10.624,-102.508 7.957,-51.568 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path70" />
|
||||
</g>
|
||||
<g
|
||||
id="g72"
|
||||
transform="translate(3785.0002,2231.1594)">
|
||||
<path
|
||||
d="m 0,0 c -89.435,600.389 -503.75,836.382 -835.551,928.658 -101.644,28.211 -201.029,34.8 -292.614,21.578 701.022,-178.425 763.759,-722.13 763.759,-722.13 L -2203.609,-854.969 c 26.811,-55.24 50.457,-114.843 69.661,-179.912 74.974,-47.243 147.304,-104.857 213.004,-174.912 432.228,247.791 1450.432,832.059 1852.067,1067.048 C -18.502,-113.273 8.537,-57.249 0,0"
|
||||
style="fill:#dfb673;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path74" />
|
||||
</g>
|
||||
<g
|
||||
id="g76"
|
||||
transform="translate(1622.9934,819.6443)">
|
||||
<path
|
||||
d="m 0,0 h -0.004 c 126.742,-143.116 225.97,-335.55 265.98,-594.762 4.99,-32.278 1.239,-64.322 -9.95,-93.477 63.577,43.289 147.771,101.106 185.257,129.007 41.619,30.947 62.193,84.461 53.671,139.637 -40.014,259.236 -140.917,449.955 -269.322,590.289 -0.741,0.827 -1.754,1.313 -2.326,2.317 -0.071,0.126 -0.063,0.268 -0.134,0.396 C 229.222,60.789 112.187,-66.136 0,0"
|
||||
style="fill:#dfb673;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path78" />
|
||||
</g>
|
||||
<g
|
||||
id="g80"
|
||||
transform="translate(1860.2659,968.5193)">
|
||||
<path
|
||||
d="m 0,0 c -11.142,0 -21.97,5.807 -27.918,16.164 -8.82,15.379 -3.499,35.026 11.896,43.847 425.41,243.871 1458.6,836.696 1863.766,1073.757 44.679,26.145 68.657,75.766 61.094,126.487 -88.164,591.851 -496.735,824.55 -823.97,915.538 -171.511,47.676 -336.838,34.369 -465.553,-37.506 C 88.572,1841.938 -1496.704,919.65 -1512.648,910.36 c -15.316,-8.914 -35.011,-3.735 -43.941,11.613 -8.929,15.348 -3.735,35.027 11.613,43.941 15.96,9.29 1601.706,931.859 2132.967,1228.492 143.562,80.192 326.167,95.571 514.093,43.344 345.579,-96.104 777.094,-342.048 870.343,-968.017 11.439,-76.834 -24.749,-151.972 -92.23,-191.456 C 1474.812,841.09 441.433,248.171 15.96,4.269 10.907,1.381 5.414,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path82" />
|
||||
</g>
|
||||
<g
|
||||
id="g84"
|
||||
transform="translate(1879.4592,1514.5154)">
|
||||
<path
|
||||
d="m 0,0 c -11.111,0 -21.908,5.744 -27.872,16.07 -8.866,15.379 -3.609,35.027 11.77,43.909 l 1611.625,930.448 c 15.395,8.913 35.011,3.609 43.91,-11.77 8.866,-15.379 3.609,-35.027 -11.77,-43.909 L 16.038,4.3 C 10.969,1.381 5.445,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path86" />
|
||||
</g>
|
||||
<g
|
||||
id="g88"
|
||||
transform="translate(537.4896,1087.4734)">
|
||||
<path
|
||||
d="m 0,0 c -3.139,0 -6.324,0.439 -9.463,1.412 l -391.089,120.304 c -90.063,27.682 -147.03,115.031 -135.448,207.683 27.699,221.587 123.646,392.831 277.455,495.308 153.762,102.413 351.998,127.24 558.3,69.928 299.472,-83.267 686.843,-287.593 831.455,-788.047 4.912,-17.043 -4.912,-34.87 -21.955,-39.798 -17.074,-4.803 -34.87,4.897 -39.797,21.971 C 933.068,560.717 566.241,753.805 282.524,832.679 94.551,884.968 -84.963,863.092 -222.906,771.193 -360.691,679.419 -446.91,523.9 -472.223,321.427 c -7.705,-61.612 30.382,-119.77 90.565,-138.257 L 9.432,62.866 C 26.396,57.625 35.922,39.641 30.696,22.692 26.458,8.882 13.731,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path90" />
|
||||
</g>
|
||||
<g
|
||||
id="g92"
|
||||
transform="translate(741.1712,1240.1677)">
|
||||
<path
|
||||
d="m 0,0 c -3.39,0 -6.827,0.533 -10.216,1.663 -82.436,27.651 -169.345,61.016 -238.411,91.491 -112.63,49.747 -107.514,110.166 -100.091,133.831 21.688,69.176 135.306,102.446 264.1,77.4 17.42,-3.39 28.797,-20.244 25.408,-37.664 -3.374,-17.45 -20.198,-28.939 -37.68,-25.423 -109.82,21.249 -183.422,-10.986 -190.499,-33.552 -3.013,-9.604 11.739,-32.391 64.719,-55.773 C -155.315,122.25 -70.431,89.671 10.216,62.615 27.055,56.966 36.11,38.762 30.476,21.907 25.957,8.475 13.433,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path94" />
|
||||
</g>
|
||||
<g
|
||||
id="g96"
|
||||
transform="translate(1215.0105,1321.1443)">
|
||||
<path
|
||||
d="m 0,0 c -12.021,0 -23.556,6.779 -29.048,18.361 -101.205,213.365 -492.891,570.666 -1057.688,313.361 -16.18,-7.344 -35.215,-0.22 -42.576,15.913 -7.359,16.164 -0.219,35.216 15.929,42.591 C -505.116,667.399 -81.008,277.957 29.017,45.918 36.627,29.88 29.801,10.703 13.763,3.107 9.306,1.004 4.614,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path98" />
|
||||
</g>
|
||||
<g
|
||||
id="g100"
|
||||
transform="translate(1901.304,2601.8943)">
|
||||
<path
|
||||
d="m 0,0 c -55.57,0 -78.717,-12.648 -80.742,-18.675 -6.936,-20.652 77.038,-105.081 264.147,-167.948 82.029,-27.557 162.111,-43.282 225.495,-44.254 60.168,-1.319 84.414,12.397 86.501,18.643 6.937,20.652 -77.038,105.081 -264.147,167.948 C 149.226,-16.729 69.144,-1.004 5.759,-0.031 3.798,0 1.883,0 0,0 m 414.833,-295.188 c -2.276,0 -4.567,0 -6.905,0.063 -69.835,1.067 -156.838,17.953 -244.987,47.55 -168.088,56.464 -335.111,158.595 -304.604,249.364 9.871,29.378 43.847,64.342 148.395,62.428 69.834,-1.068 156.837,-17.953 244.986,-47.55 168.09,-56.465 335.112,-158.596 304.604,-249.365 -9.635,-28.719 -42.12,-62.49 -141.489,-62.49"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path102" />
|
||||
</g>
|
||||
<g
|
||||
id="g104"
|
||||
transform="translate(1396.988,2309.217)">
|
||||
<path
|
||||
d="m 0,0 c -55.554,0 -78.717,-12.648 -80.741,-18.675 -6.936,-20.652 77.037,-105.081 264.147,-167.948 82.028,-27.557 162.111,-43.282 225.495,-44.255 59.995,-1.569 84.414,12.398 86.501,18.644 6.936,20.652 -77.038,105.081 -264.148,167.948 C 149.227,-16.729 69.144,-1.004 5.76,-0.031 3.798,0 1.883,0 0,0 m 414.833,-295.188 c -2.275,0 -4.566,0 -6.905,0.063 -69.834,1.066 -156.837,17.953 -244.986,47.55 -168.089,56.464 -335.111,158.595 -304.604,249.364 9.855,29.378 44.145,65.409 148.394,62.427 69.835,-1.067 156.838,-17.952 244.986,-47.55 168.09,-56.464 335.113,-158.595 304.605,-249.364 -9.636,-28.719 -42.12,-62.49 -141.49,-62.49"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path106" />
|
||||
</g>
|
||||
<g
|
||||
id="g108"
|
||||
transform="translate(2403.4377,2891.6843)">
|
||||
<path
|
||||
d="m 0,0 c -55.554,0 -78.717,-12.648 -80.741,-18.675 -6.937,-20.683 77.021,-105.113 264.147,-167.979 82.028,-27.557 162.111,-43.282 225.495,-44.223 59.759,-1.476 84.413,12.397 86.5,18.643 6.937,20.652 -77.037,105.081 -264.147,167.948 C 149.227,-16.729 69.144,-1.004 5.76,-0.031 3.798,0 1.883,0 0,0 m 414.786,-295.22 c -2.26,0 -4.535,0.032 -6.858,0.063 -69.834,1.067 -156.853,17.953 -244.986,47.551 -168.089,56.463 -335.112,158.594 -304.604,249.395 9.855,29.378 44.317,64.781 148.394,62.427 69.834,-1.067 156.837,-17.952 244.986,-47.55 168.09,-56.464 335.112,-158.595 304.604,-249.364 -9.635,-28.719 -42.135,-62.522 -141.536,-62.522"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path110" />
|
||||
</g>
|
||||
<g
|
||||
id="g112"
|
||||
transform="translate(1808.1174,31.7625)">
|
||||
<path
|
||||
d="m 0,0 c -10.326,0 -20.479,4.96 -26.694,14.188 -9.902,14.719 -6.011,34.681 8.71,44.599 1.993,1.35 201.092,135.526 264.555,182.763 36.832,27.368 54.989,75.012 47.378,124.29 -90.55,586.61 -497.442,817.582 -822.84,908.037 -194.516,54.11 -380.794,27.494 -524.559,-74.856 -59.76,-42.56 -241.989,-167.101 -243.825,-168.357 -14.626,-9.98 -34.635,-6.214 -44.663,8.412 -10.012,14.657 -6.246,34.651 8.412,44.695 1.82,1.223 183.531,125.387 242.789,167.602 159.866,113.838 365.478,143.844 579.077,84.461 C -168.042,1240.294 261.699,996.14 357.475,375.632 368.727,302.753 340.935,231.601 284.925,189.95 220.27,141.866 26.161,11.017 17.922,5.493 12.414,1.789 6.167,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path114" />
|
||||
</g>
|
||||
<g
|
||||
id="g116"
|
||||
transform="translate(822.4149,1153.447)">
|
||||
<path
|
||||
d="m 0,0 c -114.466,0 -220.521,-30.131 -309.375,-89.325 -137.786,-91.774 -224.004,-247.293 -249.317,-449.766 -7.706,-61.611 30.381,-119.77 90.581,-138.288 l 1319.763,-405.92 c 43.69,-13.433 88.493,-4.08 122.924,25.706 34.871,30.131 51.443,75.61 44.334,121.685 C 928.36,-349.267 521.452,-118.295 196.054,-27.84 129.139,-9.228 63.274,0 0,0 m 890.351,-1153.447 c -19.192,0 -38.589,2.856 -57.594,8.694 l -1319.779,405.919 c -90.079,27.746 -147.029,115.094 -135.447,207.715 27.698,221.556 123.646,392.831 277.47,495.307 153.73,102.414 352.014,127.209 558.285,69.929 343.632,-95.54 773.358,-339.694 869.149,-960.233 10.515,-68.076 -14.092,-135.401 -65.817,-180.126 -35.796,-30.915 -80.49,-47.205 -126.267,-47.205"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path118" />
|
||||
</g>
|
||||
<g
|
||||
id="g120"
|
||||
transform="translate(1326.1492,360.5037)">
|
||||
<path
|
||||
d="m 0,0 c -186.325,0 -506.214,121.339 -635.149,178.273 -112.614,49.748 -107.514,110.166 -100.107,133.8 21.704,69.207 135.228,102.477 264.116,77.431 17.419,-3.39 28.797,-20.244 25.407,-37.664 -3.374,-17.419 -20.15,-28.938 -37.679,-25.423 -109.727,21.249 -183.421,-11.017 -190.499,-33.552 -3.013,-9.604 11.738,-32.39 64.718,-55.773 223.44,-98.679 582.923,-209.944 676.705,-162.048 10.577,5.398 13.057,11.08 14.092,15.944 9.73,45.385 -55.852,197.389 -215.921,326.105 -140.8,113.21 -440.398,274.221 -865.102,80.725 -16.163,-7.376 -35.215,-0.251 -42.575,15.945 -7.36,16.132 -0.22,35.183 15.928,42.559 456.044,207.746 779.699,33.395 932.033,-89.137 C 75.625,330.78 161.859,158.688 144.471,77.492 138.9,51.536 122.391,30.884 96.733,17.796 72.393,5.366 39.123,0.031 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path122" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 18 KiB |
BIN
screenshots/community.png
Normal file
After Width: | Height: | Size: 301 KiB |
BIN
screenshots/community2.png
Normal file
After Width: | Height: | Size: 362 KiB |
BIN
screenshots/drawer.png
Normal file
After Width: | Height: | Size: 521 KiB |
BIN
screenshots/emojis.png
Normal file
After Width: | Height: | Size: 592 KiB |
251
screenshots/icon.svg
Normal file
@ -0,0 +1,251 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg18"
|
||||
width="1000"
|
||||
height="1000"
|
||||
viewBox="0 0 999.99999 1000"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs22">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath34">
|
||||
<path
|
||||
d="M 0,3236.692 H 3834.782 V 0 H 0 Z"
|
||||
id="path32" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview20"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.47594916"
|
||||
inkscape:cx="401.30337"
|
||||
inkscape:cy="504.25554"
|
||||
inkscape:window-width="2506"
|
||||
inkscape:window-height="1376"
|
||||
inkscape:window-x="54"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g26">
|
||||
<inkscape:page
|
||||
x="0"
|
||||
y="0"
|
||||
id="page24"
|
||||
width="1000"
|
||||
height="1000"
|
||||
margin="0"
|
||||
bleed="0" />
|
||||
</sodipodi:namedview>
|
||||
<g
|
||||
id="g26"
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Page 1"
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,4315.5867)">
|
||||
<circle
|
||||
style="fill:#8db600;stroke-width:1.5;stroke-linecap:round"
|
||||
id="path1"
|
||||
cx="375"
|
||||
cy="-2861.6902"
|
||||
r="375"
|
||||
transform="scale(1,-1)" />
|
||||
<g
|
||||
id="g28"
|
||||
transform="matrix(0.13958769,0,0,0.13958769,107.56017,2640.7112)">
|
||||
<g
|
||||
id="g30"
|
||||
clip-path="url(#clipPath34)">
|
||||
<g
|
||||
id="g36"
|
||||
transform="translate(151.1082,1255.2644)">
|
||||
<path
|
||||
d="m 0,0 383.651,-118.015 c 58.837,40.336 152.415,104.657 191.943,132.798 155.817,110.95 356.643,140.171 565.44,82.044 C 1242.702,68.565 1351.981,26.975 1457.753,-33.701 1310.3,415.161 952.387,602.78 673.221,680.393 480.729,733.844 296.507,711.339 154.562,616.772 12.774,522.331 -75.876,362.857 -101.786,155.613 -110.464,86.25 -67.653,20.81 0,0"
|
||||
style="fill:#dd8577;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path38" />
|
||||
</g>
|
||||
<g
|
||||
id="g40"
|
||||
transform="translate(1243.6663,1734.3396)">
|
||||
<path
|
||||
d="m 0,0 c 63.09,-84.965 117.079,-184.946 156.076,-303.659 -105.772,60.676 -215.051,102.266 -316.719,130.528 -208.798,58.127 -409.623,28.906 -565.44,-82.044 -39.524,-28.141 -133.102,-92.462 -191.943,-132.798 l -273.054,83.994 c -0.965,-6.589 -2.428,-12.792 -3.264,-19.483 -8.678,-69.363 34.133,-134.804 101.786,-155.613 l 383.651,-118.015 c 58.837,40.336 152.415,104.656 191.943,132.798 155.817,110.95 356.643,140.171 565.44,82.044 101.668,-28.262 210.947,-69.853 316.719,-130.528 C 287.679,-276.804 151.831,-113.546 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path42" />
|
||||
</g>
|
||||
<g
|
||||
id="g44"
|
||||
transform="translate(1270.5486,2305.8904)">
|
||||
<path
|
||||
d="m 0,0 c 11.707,34.838 57.233,53.859 132.937,51.473 68.218,-1.035 153.494,-17.639 240.105,-46.734 161.796,-54.33 320,-153.071 294.498,-228.994 -11.346,-33.772 -54.911,-51.537 -126.173,-51.537 -2.229,0 -4.473,0 -6.764,0.063 -68.218,1.036 -153.495,17.639 -240.105,46.734 C 132.701,-174.665 -25.501,-75.924 0,0 m 504.315,292.677 c 11.692,34.839 56.998,54.11 132.937,51.474 68.218,-1.036 153.495,-17.639 240.105,-46.734 161.797,-54.33 320,-153.072 294.498,-228.995 -11.346,-33.772 -54.91,-51.536 -126.173,-51.536 -2.228,0 -4.473,0 -6.764,0.062 C 970.701,17.984 885.423,34.587 798.813,63.683 637.016,118.012 478.814,216.754 504.315,292.677 m 502.135,289.79 c 11.691,34.839 57.405,54.141 132.936,51.473 68.218,-1.035 153.495,-17.639 240.106,-46.733 161.796,-54.331 319.999,-153.072 294.497,-228.995 -11.346,-33.772 -54.942,-51.568 -126.22,-51.568 -2.213,0 -4.441,0.031 -6.701,0.063 -68.234,1.036 -153.51,17.639 -240.121,46.734 -161.796,54.361 -319.999,153.102 -294.497,229.026 M -437.62,-339.287 c 294.169,-81.784 674.006,-282.022 818.124,-770.325 74.974,-47.242 147.304,-104.857 213.003,-174.912 432.229,247.791 1450.433,832.059 1852.068,1067.048 50.375,29.472 77.414,85.497 68.877,142.745 -89.435,600.389 -503.75,836.382 -835.551,928.658 -175.622,48.743 -345.265,34.933 -477.699,-38.981 -431.61,-241.01 -1560.183,-895.715 -1975.758,-1137.138 104.905,20.82 219.517,15.546 336.936,-17.095"
|
||||
style="fill:#f4da8f;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path46" />
|
||||
</g>
|
||||
<g
|
||||
id="g48"
|
||||
transform="translate(1575.2781,2107.3401)">
|
||||
<path
|
||||
d="m 0,0 c 83.566,-28.059 165.375,-44.066 230.375,-45.071 66.901,-0.533 96.921,14.658 101.959,29.598 12.131,36.094 -86.344,125.168 -274.253,188.318 -83.567,28.059 -165.375,44.066 -230.376,45.071 -2.119,0.031 -4.19,0.031 -6.246,0.031 -63.086,0 -90.848,-15.129 -95.712,-29.629 C -286.384,152.224 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path50" />
|
||||
</g>
|
||||
<g
|
||||
id="g52"
|
||||
transform="translate(2581.7278,2689.7761)">
|
||||
<path
|
||||
d="m 0,0 c 83.55,-28.059 165.358,-44.066 230.359,-45.039 67.418,-0.722 96.953,14.626 101.975,29.597 12.131,36.094 -86.344,125.169 -274.254,188.318 -83.565,28.06 -165.374,44.066 -230.375,45.071 -2.119,0.031 -4.19,0.031 -6.246,0.031 -63.087,0 -90.848,-15.128 -95.713,-29.629 C -286.385,152.255 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path54" />
|
||||
</g>
|
||||
<g
|
||||
id="g56"
|
||||
transform="translate(2079.594,2400.0178)">
|
||||
<path
|
||||
d="m 0,0 c 83.565,-28.06 165.374,-44.066 230.375,-45.071 66.821,-0.816 96.921,14.626 101.959,29.597 12.131,36.095 -86.344,125.169 -274.254,188.318 -83.566,28.06 -165.375,44.067 -230.376,45.071 -2.118,0.031 -4.189,0.031 -6.245,0.031 -63.087,0 -90.848,-15.128 -95.713,-29.628 C -286.385,152.224 -187.91,63.149 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path58" />
|
||||
</g>
|
||||
<g
|
||||
id="g60"
|
||||
transform="translate(1846.2991,992.655)">
|
||||
<path
|
||||
d="m 0,0 c -0.071,0.126 -0.063,0.269 -0.133,0.396 -66.132,71.751 -139.481,130.152 -215.499,177.8 -0.502,0.253 -0.941,0.561 -1.412,0.866 -114.36,71.446 -234.699,118.561 -345.713,149.427 -199.35,55.397 -390.43,27.965 -538.197,-77.273 -20.511,-14.605 -55.557,-38.939 -92.723,-64.589 115.91,31.651 245.543,29.815 378.762,-7.223 339.082,-94.253 763.112,-335.111 857.585,-947.176 4.99,-32.279 1.239,-64.323 -9.949,-93.478 63.576,43.289 147.77,101.106 185.257,129.007 41.619,30.947 62.192,84.461 53.67,139.638 C 231.635,-333.37 130.731,-142.65 2.327,-2.316 1.585,-1.489 0.573,-1.004 0,0"
|
||||
style="fill:#f4da8f;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path62" />
|
||||
</g>
|
||||
<g
|
||||
id="g64"
|
||||
transform="translate(1857.2058,219.9871)">
|
||||
<path
|
||||
d="m 0,0 c -91.868,595.115 -504.472,829.383 -834.421,921.125 -68.359,18.989 -135.667,28.405 -200.417,28.405 -116.569,0 -224.75,-30.593 -315.782,-90.532 -2.688,-1.84 -4.641,-3.172 -4.798,-3.282 0,-0.002 -0.01,-0.002 -0.01,-0.004 -140.36,-94.644 -228.241,-253.33 -254.002,-459.366 -8.678,-69.332 34.133,-134.804 101.786,-155.645 l 1319.779,-405.919 c 48.366,-14.877 100.029,-4.049 138.163,28.906 C -10.624,-102.508 7.957,-51.568 0,0"
|
||||
style="fill:#dd8577;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path66" />
|
||||
</g>
|
||||
<g
|
||||
id="g68"
|
||||
transform="translate(1857.2058,219.9871)">
|
||||
<path
|
||||
d="m 0,0 c -74.837,484.785 -362.504,730.083 -644.958,853.979 107.872,-134.752 191.257,-310.541 226.728,-540.302 7.956,-51.569 -10.625,-102.508 -49.701,-136.312 -38.134,-32.955 -89.796,-43.783 -138.162,-28.906 l -993.676,305.62 c -3.617,-18.93 -7.179,-37.91 -9.659,-57.733 -8.678,-69.332 34.133,-134.804 101.786,-155.645 l 1319.779,-405.919 c 48.366,-14.877 100.029,-4.049 138.163,28.906 C -10.624,-102.508 7.957,-51.568 0,0"
|
||||
style="fill:#bc5757;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path70" />
|
||||
</g>
|
||||
<g
|
||||
id="g72"
|
||||
transform="translate(3785.0002,2231.1594)">
|
||||
<path
|
||||
d="m 0,0 c -89.435,600.389 -503.75,836.382 -835.551,928.658 -101.644,28.211 -201.029,34.8 -292.614,21.578 701.022,-178.425 763.759,-722.13 763.759,-722.13 L -2203.609,-854.969 c 26.811,-55.24 50.457,-114.843 69.661,-179.912 74.974,-47.243 147.304,-104.857 213.004,-174.912 432.228,247.791 1450.432,832.059 1852.067,1067.048 C -18.502,-113.273 8.537,-57.249 0,0"
|
||||
style="fill:#dfb673;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path74" />
|
||||
</g>
|
||||
<g
|
||||
id="g76"
|
||||
transform="translate(1622.9934,819.6443)">
|
||||
<path
|
||||
d="m 0,0 h -0.004 c 126.742,-143.116 225.97,-335.55 265.98,-594.762 4.99,-32.278 1.239,-64.322 -9.95,-93.477 63.577,43.289 147.771,101.106 185.257,129.007 41.619,30.947 62.193,84.461 53.671,139.637 -40.014,259.236 -140.917,449.955 -269.322,590.289 -0.741,0.827 -1.754,1.313 -2.326,2.317 -0.071,0.126 -0.063,0.268 -0.134,0.396 C 229.222,60.789 112.187,-66.136 0,0"
|
||||
style="fill:#dfb673;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path78" />
|
||||
</g>
|
||||
<g
|
||||
id="g80"
|
||||
transform="translate(1860.2659,968.5193)">
|
||||
<path
|
||||
d="m 0,0 c -11.142,0 -21.97,5.807 -27.918,16.164 -8.82,15.379 -3.499,35.026 11.896,43.847 425.41,243.871 1458.6,836.696 1863.766,1073.757 44.679,26.145 68.657,75.766 61.094,126.487 -88.164,591.851 -496.735,824.55 -823.97,915.538 -171.511,47.676 -336.838,34.369 -465.553,-37.506 C 88.572,1841.938 -1496.704,919.65 -1512.648,910.36 c -15.316,-8.914 -35.011,-3.735 -43.941,11.613 -8.929,15.348 -3.735,35.027 11.613,43.941 15.96,9.29 1601.706,931.859 2132.967,1228.492 143.562,80.192 326.167,95.571 514.093,43.344 345.579,-96.104 777.094,-342.048 870.343,-968.017 11.439,-76.834 -24.749,-151.972 -92.23,-191.456 C 1474.812,841.09 441.433,248.171 15.96,4.269 10.907,1.381 5.414,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path82" />
|
||||
</g>
|
||||
<g
|
||||
id="g84"
|
||||
transform="translate(1879.4592,1514.5154)">
|
||||
<path
|
||||
d="m 0,0 c -11.111,0 -21.908,5.744 -27.872,16.07 -8.866,15.379 -3.609,35.027 11.77,43.909 l 1611.625,930.448 c 15.395,8.913 35.011,3.609 43.91,-11.77 8.866,-15.379 3.609,-35.027 -11.77,-43.909 L 16.038,4.3 C 10.969,1.381 5.445,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path86" />
|
||||
</g>
|
||||
<g
|
||||
id="g88"
|
||||
transform="translate(537.4896,1087.4734)">
|
||||
<path
|
||||
d="m 0,0 c -3.139,0 -6.324,0.439 -9.463,1.412 l -391.089,120.304 c -90.063,27.682 -147.03,115.031 -135.448,207.683 27.699,221.587 123.646,392.831 277.455,495.308 153.762,102.413 351.998,127.24 558.3,69.928 299.472,-83.267 686.843,-287.593 831.455,-788.047 4.912,-17.043 -4.912,-34.87 -21.955,-39.798 -17.074,-4.803 -34.87,4.897 -39.797,21.971 C 933.068,560.717 566.241,753.805 282.524,832.679 94.551,884.968 -84.963,863.092 -222.906,771.193 -360.691,679.419 -446.91,523.9 -472.223,321.427 c -7.705,-61.612 30.382,-119.77 90.565,-138.257 L 9.432,62.866 C 26.396,57.625 35.922,39.641 30.696,22.692 26.458,8.882 13.731,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path90" />
|
||||
</g>
|
||||
<g
|
||||
id="g92"
|
||||
transform="translate(741.1712,1240.1677)">
|
||||
<path
|
||||
d="m 0,0 c -3.39,0 -6.827,0.533 -10.216,1.663 -82.436,27.651 -169.345,61.016 -238.411,91.491 -112.63,49.747 -107.514,110.166 -100.091,133.831 21.688,69.176 135.306,102.446 264.1,77.4 17.42,-3.39 28.797,-20.244 25.408,-37.664 -3.374,-17.45 -20.198,-28.939 -37.68,-25.423 -109.82,21.249 -183.422,-10.986 -190.499,-33.552 -3.013,-9.604 11.739,-32.391 64.719,-55.773 C -155.315,122.25 -70.431,89.671 10.216,62.615 27.055,56.966 36.11,38.762 30.476,21.907 25.957,8.475 13.433,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path94" />
|
||||
</g>
|
||||
<g
|
||||
id="g96"
|
||||
transform="translate(1215.0105,1321.1443)">
|
||||
<path
|
||||
d="m 0,0 c -12.021,0 -23.556,6.779 -29.048,18.361 -101.205,213.365 -492.891,570.666 -1057.688,313.361 -16.18,-7.344 -35.215,-0.22 -42.576,15.913 -7.359,16.164 -0.219,35.216 15.929,42.591 C -505.116,667.399 -81.008,277.957 29.017,45.918 36.627,29.88 29.801,10.703 13.763,3.107 9.306,1.004 4.614,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path98" />
|
||||
</g>
|
||||
<g
|
||||
id="g100"
|
||||
transform="translate(1901.304,2601.8943)">
|
||||
<path
|
||||
d="m 0,0 c -55.57,0 -78.717,-12.648 -80.742,-18.675 -6.936,-20.652 77.038,-105.081 264.147,-167.948 82.029,-27.557 162.111,-43.282 225.495,-44.254 60.168,-1.319 84.414,12.397 86.501,18.643 6.937,20.652 -77.038,105.081 -264.147,167.948 C 149.226,-16.729 69.144,-1.004 5.759,-0.031 3.798,0 1.883,0 0,0 m 414.833,-295.188 c -2.276,0 -4.567,0 -6.905,0.063 -69.835,1.067 -156.838,17.953 -244.987,47.55 -168.088,56.464 -335.111,158.595 -304.604,249.364 9.871,29.378 43.847,64.342 148.395,62.428 69.834,-1.068 156.837,-17.953 244.986,-47.55 168.09,-56.465 335.112,-158.596 304.604,-249.365 -9.635,-28.719 -42.12,-62.49 -141.489,-62.49"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path102" />
|
||||
</g>
|
||||
<g
|
||||
id="g104"
|
||||
transform="translate(1396.988,2309.217)">
|
||||
<path
|
||||
d="m 0,0 c -55.554,0 -78.717,-12.648 -80.741,-18.675 -6.936,-20.652 77.037,-105.081 264.147,-167.948 82.028,-27.557 162.111,-43.282 225.495,-44.255 59.995,-1.569 84.414,12.398 86.501,18.644 6.936,20.652 -77.038,105.081 -264.148,167.948 C 149.227,-16.729 69.144,-1.004 5.76,-0.031 3.798,0 1.883,0 0,0 m 414.833,-295.188 c -2.275,0 -4.566,0 -6.905,0.063 -69.834,1.066 -156.837,17.953 -244.986,47.55 -168.089,56.464 -335.111,158.595 -304.604,249.364 9.855,29.378 44.145,65.409 148.394,62.427 69.835,-1.067 156.838,-17.952 244.986,-47.55 168.09,-56.464 335.113,-158.595 304.605,-249.364 -9.636,-28.719 -42.12,-62.49 -141.49,-62.49"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path106" />
|
||||
</g>
|
||||
<g
|
||||
id="g108"
|
||||
transform="translate(2403.4377,2891.6843)">
|
||||
<path
|
||||
d="m 0,0 c -55.554,0 -78.717,-12.648 -80.741,-18.675 -6.937,-20.683 77.021,-105.113 264.147,-167.979 82.028,-27.557 162.111,-43.282 225.495,-44.223 59.759,-1.476 84.413,12.397 86.5,18.643 6.937,20.652 -77.037,105.081 -264.147,167.948 C 149.227,-16.729 69.144,-1.004 5.76,-0.031 3.798,0 1.883,0 0,0 m 414.786,-295.22 c -2.26,0 -4.535,0.032 -6.858,0.063 -69.834,1.067 -156.853,17.953 -244.986,47.551 -168.089,56.463 -335.112,158.594 -304.604,249.395 9.855,29.378 44.317,64.781 148.394,62.427 69.834,-1.067 156.837,-17.952 244.986,-47.55 168.09,-56.464 335.112,-158.595 304.604,-249.364 -9.635,-28.719 -42.135,-62.522 -141.536,-62.522"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path110" />
|
||||
</g>
|
||||
<g
|
||||
id="g112"
|
||||
transform="translate(1808.1174,31.7625)">
|
||||
<path
|
||||
d="m 0,0 c -10.326,0 -20.479,4.96 -26.694,14.188 -9.902,14.719 -6.011,34.681 8.71,44.599 1.993,1.35 201.092,135.526 264.555,182.763 36.832,27.368 54.989,75.012 47.378,124.29 -90.55,586.61 -497.442,817.582 -822.84,908.037 -194.516,54.11 -380.794,27.494 -524.559,-74.856 -59.76,-42.56 -241.989,-167.101 -243.825,-168.357 -14.626,-9.98 -34.635,-6.214 -44.663,8.412 -10.012,14.657 -6.246,34.651 8.412,44.695 1.82,1.223 183.531,125.387 242.789,167.602 159.866,113.838 365.478,143.844 579.077,84.461 C -168.042,1240.294 261.699,996.14 357.475,375.632 368.727,302.753 340.935,231.601 284.925,189.95 220.27,141.866 26.161,11.017 17.922,5.493 12.414,1.789 6.167,0 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path114" />
|
||||
</g>
|
||||
<g
|
||||
id="g116"
|
||||
transform="translate(822.4149,1153.447)">
|
||||
<path
|
||||
d="m 0,0 c -114.466,0 -220.521,-30.131 -309.375,-89.325 -137.786,-91.774 -224.004,-247.293 -249.317,-449.766 -7.706,-61.611 30.381,-119.77 90.581,-138.288 l 1319.763,-405.92 c 43.69,-13.433 88.493,-4.08 122.924,25.706 34.871,30.131 51.443,75.61 44.334,121.685 C 928.36,-349.267 521.452,-118.295 196.054,-27.84 129.139,-9.228 63.274,0 0,0 m 890.351,-1153.447 c -19.192,0 -38.589,2.856 -57.594,8.694 l -1319.779,405.919 c -90.079,27.746 -147.029,115.094 -135.447,207.715 27.698,221.556 123.646,392.831 277.47,495.307 153.73,102.414 352.014,127.209 558.285,69.929 343.632,-95.54 773.358,-339.694 869.149,-960.233 10.515,-68.076 -14.092,-135.401 -65.817,-180.126 -35.796,-30.915 -80.49,-47.205 -126.267,-47.205"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path118" />
|
||||
</g>
|
||||
<g
|
||||
id="g120"
|
||||
transform="translate(1326.1492,360.5037)">
|
||||
<path
|
||||
d="m 0,0 c -186.325,0 -506.214,121.339 -635.149,178.273 -112.614,49.748 -107.514,110.166 -100.107,133.8 21.704,69.207 135.228,102.477 264.116,77.431 17.419,-3.39 28.797,-20.244 25.407,-37.664 -3.374,-17.419 -20.15,-28.938 -37.679,-25.423 -109.727,21.249 -183.421,-11.017 -190.499,-33.552 -3.013,-9.604 11.738,-32.39 64.718,-55.773 223.44,-98.679 582.923,-209.944 676.705,-162.048 10.577,5.398 13.057,11.08 14.092,15.944 9.73,45.385 -55.852,197.389 -215.921,326.105 -140.8,113.21 -440.398,274.221 -865.102,80.725 -16.163,-7.376 -35.215,-0.251 -42.575,15.945 -7.36,16.132 -0.22,35.183 15.928,42.559 456.044,207.746 779.699,33.395 932.033,-89.137 C 75.625,330.78 161.859,158.688 144.471,77.492 138.9,51.536 122.391,30.884 96.733,17.796 72.393,5.366 39.123,0.031 0,0"
|
||||
style="fill:#433c35;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path122" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 18 KiB |
BIN
screenshots/photography.png
Normal file
After Width: | Height: | Size: 2.8 MiB |
BIN
screenshots/profile.png
Normal file
After Width: | Height: | Size: 314 KiB |
BIN
screenshots/streaming.png
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/streaming2.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
screenshots/streams.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
63
scripts/build-icons.mjs
Normal file
@ -0,0 +1,63 @@
|
||||
import cheerio from "cheerio";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import camelcase from "camelcase";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
const prettierConfig = JSON.parse(await fs.readFile(".prettierrc", { encoding: "utf-8" }));
|
||||
|
||||
const iconsSrc = "./src/components/icons/svg/untitledui-icons";
|
||||
const iconsDist = "./src/components/icons";
|
||||
const iconsList = await fs.readdir(iconsSrc);
|
||||
|
||||
for (const filename of iconsList) {
|
||||
const content = await fs.readFile(path.join(iconsSrc, filename), { encoding: "utf-8" });
|
||||
const componentName = camelcase(path.basename(filename, ".svg"), { pascalCase: true });
|
||||
|
||||
const $ = cheerio.load(content);
|
||||
|
||||
const viewBox = $("svg").attr("viewBox");
|
||||
const pathElements = $("path");
|
||||
const paths = [];
|
||||
for (const path of pathElements) {
|
||||
// convert all attributes to camelcase
|
||||
for (const [key, value] of Object.entries(path.attribs)) {
|
||||
const ccKey = camelcase(key);
|
||||
if (ccKey !== key) {
|
||||
delete path.attribs[key];
|
||||
path.attribs[ccKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (path.attribs["stroke"]) {
|
||||
path.attribs["stroke"] = "currentColor";
|
||||
}
|
||||
if (path.attribs["fill"]) {
|
||||
path.attribs["fill"] = "currentColor";
|
||||
} else path.attribs["fill"] = "none";
|
||||
|
||||
paths.push($.html(path));
|
||||
}
|
||||
|
||||
const outputCode = await prettier.format(
|
||||
`
|
||||
import { createIcon } from "@chakra-ui/icons";
|
||||
|
||||
const ${componentName} = createIcon({
|
||||
displayName: "${componentName}",
|
||||
viewBox: "${viewBox}",
|
||||
path: [
|
||||
${paths.join(",\n")}
|
||||
],
|
||||
defaultProps: { boxSize: 4 },
|
||||
});
|
||||
|
||||
export default ${componentName};
|
||||
`,
|
||||
{ ...prettierConfig, parser: "typescript" },
|
||||
);
|
||||
|
||||
const outputPath = path.join(iconsDist, filename.replace(".svg", ".tsx"));
|
||||
fs.writeFile(outputPath, outputCode, { encoding: "utf-8" });
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
}
|
3
server/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# noStrudel server
|
||||
|
||||
This is meant to be a simple nodejs server that proxies requests to bypass CORS or access TOR or I2P
|
42
server/dockerfile
Normal file
@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY ./package*.json .
|
||||
COPY ./yarn.lock .
|
||||
ENV NODE_ENV='development'
|
||||
RUN yarn install --production=false --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV VITE_COMMIT_HASH=""
|
||||
ENV VITE_APP_VERSION="custom"
|
||||
RUN yarn build
|
||||
|
||||
# FROM nginx:stable-alpine-slim AS main
|
||||
FROM node:20-alpine AS main
|
||||
EXPOSE 80
|
||||
|
||||
# install tor
|
||||
# copied from https://github.com/klemmchr/tor-alpine/blob/master/Dockerfile
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \
|
||||
apk -U upgrade && \
|
||||
apk -v add tor@edge torsocks@edge
|
||||
|
||||
# remove tmp files
|
||||
RUN rm -rf /var/cache/apk/* /tmp/* /var/tmp/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# copy server
|
||||
COPY server/ /app/server/
|
||||
RUN cd /app/server/ && npm install
|
||||
|
||||
# setup entrypoint
|
||||
ADD ./docker-entrypoint.sh docker-entrypoint.sh
|
||||
RUN chmod a+x docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
151
server/entrypoint.sh
Executable file
@ -0,0 +1,151 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PROXY_PASS_BLOCK=""
|
||||
|
||||
# start tor if set to true
|
||||
if [ "$TOR_PROXY" = "true" ]; then
|
||||
echo "Starting tor socks proxy"
|
||||
|
||||
tor &
|
||||
tor_process=$!
|
||||
TOR_PROXY="127.0.0.1:9050"
|
||||
fi
|
||||
|
||||
# inject request proxy
|
||||
if [ -n "$REQUEST_PROXY" ]; then
|
||||
REQUEST_PROXY_URL="$REQUEST_PROXY"
|
||||
|
||||
if [ "$REQUEST_PROXY" = "true" ]; then
|
||||
REQUEST_PROXY_URL="127.0.0.1:8080"
|
||||
fi
|
||||
|
||||
echo "Request proxy set to $REQUEST_PROXY"
|
||||
sed -i 's/REQUEST_PROXY = ""/REQUEST_PROXY = "\/request-proxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /request-proxy/ {
|
||||
proxy_pass http://$REQUEST_PROXY_URL;
|
||||
rewrite ^/request-proxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
|
||||
if [ -n "$PROXY_FIRST" ]; then
|
||||
echo "Telling app to use request proxy first"
|
||||
sed -i 's/PROXY_FIRST = false/PROXY_FIRST = true/g' /usr/share/nginx/html/index.html
|
||||
fi
|
||||
else
|
||||
echo "No request proxy set"
|
||||
fi
|
||||
|
||||
# inject cache relay URL
|
||||
if [ -n "$CACHE_RELAY" ]; then
|
||||
echo "Cache relay set to $CACHE_RELAY"
|
||||
sed -i 's/CACHE_RELAY_ENABLED = false/CACHE_RELAY_ENABLED = true/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /local-relay {
|
||||
proxy_pass http://$CACHE_RELAY/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No cache relay set"
|
||||
fi
|
||||
|
||||
# inject image proxy URL
|
||||
if [ -n "$IMAGE_PROXY" ]; then
|
||||
echo "Image proxy set to $IMAGE_PROXY"
|
||||
sed -i 's/IMAGE_PROXY_PATH = ""/IMAGE_PROXY_PATH = "\/imageproxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /imageproxy/ {
|
||||
proxy_pass http://$IMAGE_PROXY;
|
||||
rewrite ^/imageproxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No Image proxy set"
|
||||
fi
|
||||
|
||||
CONF_FILE="/etc/nginx/conf.d/default.conf"
|
||||
NGINX_CONF="
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name localhost;
|
||||
merge_slashes off;
|
||||
|
||||
$PROXY_PASS_BLOCK
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# Gzip settings
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
"
|
||||
echo "$NGINX_CONF" > $CONF_FILE
|
||||
|
||||
_term() {
|
||||
echo "Caught SIGTERM signal!"
|
||||
|
||||
# stop node server
|
||||
if [ "$REQUEST_PROXY" = "true" ]; then
|
||||
kill -SIGTERM "$node_process" 2>/dev/null
|
||||
fi
|
||||
|
||||
# stop tor if started
|
||||
if [ "$TOR_PROXY" = "true" ]; then
|
||||
kill -SIGTERM "$tor_process" 2>/dev/null
|
||||
fi
|
||||
|
||||
# stop nginx
|
||||
kill -SIGTERM "$nginx_process" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "$REQUEST_PROXY" = "true" ]; then
|
||||
echo "Starting local request proxy"
|
||||
node server/index.js &
|
||||
node_process=$!
|
||||
fi
|
||||
|
||||
nginx -g 'daemon off;' &
|
||||
nginx_process=$!
|
||||
|
||||
trap _term SIGTERM
|
||||
|
||||
wait $nginx_process
|
52
server/index.js
Normal file
@ -0,0 +1,52 @@
|
||||
var cors_proxy = require("cors-anywhere");
|
||||
var { PacProxyAgent } = require("pac-proxy-agent");
|
||||
|
||||
const { TOR_PROXY, I2P_PROXY } = process.env;
|
||||
|
||||
if (TOR_PROXY) console.log("Tor Proxy:", TOR_PROXY);
|
||||
if (I2P_PROXY) console.log("I2P Proxy:", I2P_PROXY);
|
||||
|
||||
const I2pConfig = I2P_PROXY
|
||||
? `
|
||||
if (shExpMatch(host, "*.i2p"))
|
||||
{
|
||||
return "PROXY ${I2P_PROXY}";
|
||||
}`.trim()
|
||||
: "";
|
||||
const TorConfig = TOR_PROXY
|
||||
? `
|
||||
if (shExpMatch(host, "*.onion"))
|
||||
{
|
||||
return "SOCKS5 ${TOR_PROXY}";
|
||||
}`.trim()
|
||||
: "";
|
||||
|
||||
const PACFile = `
|
||||
// SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
function FindProxyForURL(url, host)
|
||||
{
|
||||
${I2pConfig}
|
||||
${TorConfig}
|
||||
return "DIRECT";
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const PACURI = "pac+data:application/x-ns-proxy-autoconfig;base64," + btoa(PACFile);
|
||||
|
||||
var host = "127.0.0.1";
|
||||
var port = 8080;
|
||||
|
||||
cors_proxy
|
||||
.createServer({
|
||||
requireHeader: [],
|
||||
removeHeaders: ["cookie", "cookie2"],
|
||||
redirectSameOrigin: true,
|
||||
httpProxyOptions: {
|
||||
xfwd: false,
|
||||
agent: new PacProxyAgent(PACURI),
|
||||
},
|
||||
})
|
||||
.listen(port, host, () => {
|
||||
console.log("Running HTTP request proxy on " + host + ":" + port);
|
||||
});
|
11
server/package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cors-anywhere": "^0.4.4",
|
||||
"pac-proxy-agent": "^7.0.1"
|
||||
}
|
||||
}
|
856
server/yarn.lock
Normal file
@ -0,0 +1,856 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@tootallnate/quickjs-emscripten@^0.23.0":
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
|
||||
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
|
||||
|
||||
accepts@^1.3.5:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
||||
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
|
||||
dependencies:
|
||||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317"
|
||||
integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
|
||||
ajv@^6.12.3:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-json-stable-stringify "^2.0.0"
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
asn1@~0.2.3:
|
||||
version "0.2.6"
|
||||
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
|
||||
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
|
||||
dependencies:
|
||||
safer-buffer "~2.1.0"
|
||||
|
||||
assert-plus@1.0.0, assert-plus@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
|
||||
integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==
|
||||
|
||||
ast-types@^0.13.4:
|
||||
version "0.13.4"
|
||||
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782"
|
||||
integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==
|
||||
dependencies:
|
||||
tslib "^2.0.1"
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
aws-sign2@~0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||
integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==
|
||||
|
||||
aws4@^1.8.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.0.tgz#d9b802e9bb9c248d7be5f7f5ef178dc3684e9dcc"
|
||||
integrity sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==
|
||||
|
||||
basic-ftp@^5.0.2:
|
||||
version "5.0.5"
|
||||
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
|
||||
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
|
||||
|
||||
bcrypt-pbkdf@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
|
||||
integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
|
||||
dependencies:
|
||||
tweetnacl "^0.14.3"
|
||||
|
||||
cache-content-type@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
|
||||
integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
|
||||
dependencies:
|
||||
mime-types "^2.1.18"
|
||||
ylru "^1.2.0"
|
||||
|
||||
caseless@~0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
|
||||
|
||||
co@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
|
||||
integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
|
||||
|
||||
combined-stream@^1.0.6, combined-stream@~1.0.6:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
content-disposition@~0.5.2:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||
integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
|
||||
dependencies:
|
||||
safe-buffer "5.2.1"
|
||||
|
||||
content-type@^1.0.4:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
|
||||
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
|
||||
|
||||
cookies@~0.9.0:
|
||||
version "0.9.1"
|
||||
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3"
|
||||
integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==
|
||||
dependencies:
|
||||
depd "~2.0.0"
|
||||
keygrip "~1.1.0"
|
||||
|
||||
core-util-is@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||
integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==
|
||||
|
||||
cors-anywhere@^0.4.4:
|
||||
version "0.4.4"
|
||||
resolved "https://registry.yarnpkg.com/cors-anywhere/-/cors-anywhere-0.4.4.tgz#98892fcab55f408fff13a63e125135c18dc22ca8"
|
||||
integrity sha512-8OBFwnzMgR4mNrAeAyOLB2EruS2z7u02of2bOu7i9kKYlZG+niS7CTHLPgEXKWW2NAOJWRry9RRCaL9lJRjNqg==
|
||||
dependencies:
|
||||
http-proxy "1.11.1"
|
||||
proxy-from-env "0.0.1"
|
||||
|
||||
dashdash@^1.12.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
|
||||
integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
data-uri-to-buffer@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b"
|
||||
integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==
|
||||
|
||||
debug@4, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
debug@^4.3.2:
|
||||
version "4.3.5"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e"
|
||||
integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
deep-equal@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
|
||||
integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
|
||||
|
||||
degenerator@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5"
|
||||
integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==
|
||||
dependencies:
|
||||
ast-types "^0.13.4"
|
||||
escodegen "^2.1.0"
|
||||
esprima "^4.0.1"
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||
|
||||
delegates@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
|
||||
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
|
||||
|
||||
depd@^2.0.0, depd@~2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
|
||||
|
||||
depd@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
||||
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
|
||||
|
||||
destroy@^1.0.4:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
|
||||
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==
|
||||
dependencies:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ee-first@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
|
||||
|
||||
encodeurl@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
|
||||
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
|
||||
|
||||
escape-html@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||
|
||||
escodegen@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17"
|
||||
integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==
|
||||
dependencies:
|
||||
esprima "^4.0.1"
|
||||
estraverse "^5.2.0"
|
||||
esutils "^2.0.2"
|
||||
optionalDependencies:
|
||||
source-map "~0.6.1"
|
||||
|
||||
esprima@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
estraverse@^5.2.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
|
||||
esutils@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||
|
||||
eventemitter3@1.x.x:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508"
|
||||
integrity sha512-DOFqA1MF46fmZl2xtzXR3MPCRsXqgoFqdXcrCVYM3JNnfUeHTm/fh/v/iU7gBFpwkuBmoJPAm5GuhdDfSEJMJA==
|
||||
|
||||
extend@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
|
||||
extsprintf@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
|
||||
integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==
|
||||
|
||||
extsprintf@^1.2.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07"
|
||||
integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==
|
||||
|
||||
fast-deep-equal@^3.1.1:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-json-stable-stringify@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
forever-agent@~0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
|
||||
|
||||
form-data@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
|
||||
integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
fresh@~0.5.2:
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
||||
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
|
||||
|
||||
fs-extra@^11.2.0:
|
||||
version "11.2.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b"
|
||||
integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
get-uri@^6.0.1:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.3.tgz#0d26697bc13cf91092e519aa63aa60ee5b6f385a"
|
||||
integrity sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==
|
||||
dependencies:
|
||||
basic-ftp "^5.0.2"
|
||||
data-uri-to-buffer "^6.0.2"
|
||||
debug "^4.3.4"
|
||||
fs-extra "^11.2.0"
|
||||
|
||||
getpass@^0.1.1:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
|
||||
integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
|
||||
version "4.2.11"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
|
||||
har-schema@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
|
||||
integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==
|
||||
|
||||
har-validator@~5.1.3:
|
||||
version "5.1.5"
|
||||
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
|
||||
integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
|
||||
dependencies:
|
||||
ajv "^6.12.3"
|
||||
har-schema "^2.0.0"
|
||||
|
||||
has-symbols@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
|
||||
integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
|
||||
|
||||
has-tostringtag@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
|
||||
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
|
||||
dependencies:
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
http-assert@^1.3.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
|
||||
integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
|
||||
dependencies:
|
||||
deep-equal "~1.0.1"
|
||||
http-errors "~1.8.0"
|
||||
|
||||
http-errors@^1.6.3, http-errors@~1.8.0:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
|
||||
integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
|
||||
dependencies:
|
||||
depd "~1.1.2"
|
||||
inherits "2.0.4"
|
||||
setprototypeof "1.2.0"
|
||||
statuses ">= 1.5.0 < 2"
|
||||
toidentifier "1.0.1"
|
||||
|
||||
http-proxy-agent@^7.0.0:
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
|
||||
integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==
|
||||
dependencies:
|
||||
agent-base "^7.1.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
http-proxy@1.11.1:
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.11.1.tgz#71df55757e802d58ea810df2244019dda05ae85d"
|
||||
integrity sha512-qz7jZarkVG3G6GMq+4VRJPSN4NkIjL4VMTNhKGd8jc25BumeJjWWvnY3A7OkCGa8W1TTxbaK3dcE0ijFalITVA==
|
||||
dependencies:
|
||||
eventemitter3 "1.x.x"
|
||||
requires-port "0.x.x"
|
||||
|
||||
http-signature@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
|
||||
integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
jsprim "^1.2.2"
|
||||
sshpk "^1.7.0"
|
||||
|
||||
https-proxy-agent@^7.0.2:
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168"
|
||||
integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==
|
||||
dependencies:
|
||||
agent-base "^7.0.2"
|
||||
debug "4"
|
||||
|
||||
inherits@2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
ip-address@^9.0.5:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
|
||||
integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==
|
||||
dependencies:
|
||||
jsbn "1.1.0"
|
||||
sprintf-js "^1.1.3"
|
||||
|
||||
is-generator-function@^1.0.7:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
|
||||
integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
|
||||
dependencies:
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-typedarray@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
|
||||
integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
|
||||
|
||||
isstream@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
|
||||
|
||||
jsbn@1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
|
||||
integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==
|
||||
|
||||
jsbn@~0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||
integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-schema@0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
|
||||
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
||||
|
||||
json-stringify-safe@~5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
|
||||
integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
|
||||
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
|
||||
dependencies:
|
||||
universalify "^2.0.0"
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb"
|
||||
integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==
|
||||
dependencies:
|
||||
assert-plus "1.0.0"
|
||||
extsprintf "1.3.0"
|
||||
json-schema "0.4.0"
|
||||
verror "1.10.0"
|
||||
|
||||
keygrip@~1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
|
||||
integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
|
||||
dependencies:
|
||||
tsscmp "1.0.6"
|
||||
|
||||
koa-compose@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
|
||||
integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
|
||||
|
||||
koa-convert@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
|
||||
integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
|
||||
dependencies:
|
||||
co "^4.6.0"
|
||||
koa-compose "^4.1.0"
|
||||
|
||||
koa-proxy@^1.0.0-alpha.3:
|
||||
version "1.0.0-alpha.3"
|
||||
resolved "https://registry.yarnpkg.com/koa-proxy/-/koa-proxy-1.0.0-alpha.3.tgz#afc61edc9dc6a195464664beccc162cfe994bf55"
|
||||
integrity sha512-8ke0WoQKAsQ8BpkC9+I83lKsFaycE9fcLeTx12jQtEa5SrdTI6mNKR5M4LslHbvkX1hDhXreIszXsnom5Ej7RQ==
|
||||
dependencies:
|
||||
pause-stream "0.0.11"
|
||||
request "^2.88.0"
|
||||
request-promise-native "^1.0.5"
|
||||
|
||||
koa@^2.15.3:
|
||||
version "2.15.3"
|
||||
resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.3.tgz#062809266ee75ce0c75f6510a005b0e38f8c519a"
|
||||
integrity sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==
|
||||
dependencies:
|
||||
accepts "^1.3.5"
|
||||
cache-content-type "^1.0.0"
|
||||
content-disposition "~0.5.2"
|
||||
content-type "^1.0.4"
|
||||
cookies "~0.9.0"
|
||||
debug "^4.3.2"
|
||||
delegates "^1.0.0"
|
||||
depd "^2.0.0"
|
||||
destroy "^1.0.4"
|
||||
encodeurl "^1.0.2"
|
||||
escape-html "^1.0.3"
|
||||
fresh "~0.5.2"
|
||||
http-assert "^1.3.0"
|
||||
http-errors "^1.6.3"
|
||||
is-generator-function "^1.0.7"
|
||||
koa-compose "^4.1.0"
|
||||
koa-convert "^2.0.0"
|
||||
on-finished "^2.3.0"
|
||||
only "~0.0.2"
|
||||
parseurl "^1.3.2"
|
||||
statuses "^1.5.0"
|
||||
type-is "^1.6.16"
|
||||
vary "^1.1.2"
|
||||
|
||||
lodash@^4.17.19:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
media-typer@0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
|
||||
|
||||
mime-db@1.52.0:
|
||||
version "1.52.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
dependencies:
|
||||
mime-db "1.52.0"
|
||||
|
||||
ms@2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||
|
||||
negotiator@0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
|
||||
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
|
||||
|
||||
netmask@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7"
|
||||
integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
|
||||
|
||||
oauth-sign@~0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
|
||||
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
|
||||
|
||||
on-finished@^2.3.0:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
|
||||
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
|
||||
dependencies:
|
||||
ee-first "1.1.1"
|
||||
|
||||
only@~0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
|
||||
integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
|
||||
|
||||
pac-proxy-agent@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz#6b9ddc002ec3ff0ba5fdf4a8a21d363bcc612d75"
|
||||
integrity sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==
|
||||
dependencies:
|
||||
"@tootallnate/quickjs-emscripten" "^0.23.0"
|
||||
agent-base "^7.0.2"
|
||||
debug "^4.3.4"
|
||||
get-uri "^6.0.1"
|
||||
http-proxy-agent "^7.0.0"
|
||||
https-proxy-agent "^7.0.2"
|
||||
pac-resolver "^7.0.0"
|
||||
socks-proxy-agent "^8.0.2"
|
||||
|
||||
pac-resolver@^7.0.0:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6"
|
||||
integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==
|
||||
dependencies:
|
||||
degenerator "^5.0.0"
|
||||
netmask "^2.0.2"
|
||||
|
||||
parseurl@^1.3.2:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
pause-stream@0.0.11:
|
||||
version "0.0.11"
|
||||
resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
|
||||
integrity sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==
|
||||
dependencies:
|
||||
through "~2.3"
|
||||
|
||||
performance-now@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
||||
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
|
||||
|
||||
proxy-from-env@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-0.0.1.tgz#b27c4946e9e6d5dbadb7598a6435d3014c4cfd49"
|
||||
integrity sha512-B9Hnta3CATuMS0q6kt5hEezOPM+V3dgaRewkFtFoaRQYTVNsHqUvFXmndH06z3QO1ZdDnRELv5vfY6zAj/gG7A==
|
||||
|
||||
psl@^1.1.28:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
|
||||
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
|
||||
|
||||
punycode@^2.1.0, punycode@^2.1.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qs@~6.5.2:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
|
||||
integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
|
||||
|
||||
request-promise-core@1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f"
|
||||
integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==
|
||||
dependencies:
|
||||
lodash "^4.17.19"
|
||||
|
||||
request-promise-native@^1.0.5:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28"
|
||||
integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==
|
||||
dependencies:
|
||||
request-promise-core "1.1.4"
|
||||
stealthy-require "^1.1.1"
|
||||
tough-cookie "^2.3.3"
|
||||
|
||||
request@^2.88.0:
|
||||
version "2.88.2"
|
||||
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
|
||||
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
|
||||
dependencies:
|
||||
aws-sign2 "~0.7.0"
|
||||
aws4 "^1.8.0"
|
||||
caseless "~0.12.0"
|
||||
combined-stream "~1.0.6"
|
||||
extend "~3.0.2"
|
||||
forever-agent "~0.6.1"
|
||||
form-data "~2.3.2"
|
||||
har-validator "~5.1.3"
|
||||
http-signature "~1.2.0"
|
||||
is-typedarray "~1.0.0"
|
||||
isstream "~0.1.2"
|
||||
json-stringify-safe "~5.0.1"
|
||||
mime-types "~2.1.19"
|
||||
oauth-sign "~0.9.0"
|
||||
performance-now "^2.1.0"
|
||||
qs "~6.5.2"
|
||||
safe-buffer "^5.1.2"
|
||||
tough-cookie "~2.5.0"
|
||||
tunnel-agent "^0.6.0"
|
||||
uuid "^3.3.2"
|
||||
|
||||
requires-port@0.x.x:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-0.0.1.tgz#4b4414411d9df7c855995dd899a8c78a2951c16d"
|
||||
integrity sha512-AzPDCliPoWDSvEVYRQmpzuPhGGEnPrQz9YiOEvn+UdB9ixBpw+4IOZWtwctmpzySLZTy7ynpn47V14H4yaowtA==
|
||||
|
||||
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
|
||||
|
||||
smart-buffer@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||
|
||||
socks-proxy-agent@^8.0.2:
|
||||
version "8.0.3"
|
||||
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz#6b2da3d77364fde6292e810b496cb70440b9b89d"
|
||||
integrity sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==
|
||||
dependencies:
|
||||
agent-base "^7.1.1"
|
||||
debug "^4.3.4"
|
||||
socks "^2.7.1"
|
||||
|
||||
socks@^2.7.1:
|
||||
version "2.8.3"
|
||||
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5"
|
||||
integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==
|
||||
dependencies:
|
||||
ip-address "^9.0.5"
|
||||
smart-buffer "^4.2.0"
|
||||
|
||||
source-map@~0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
sprintf-js@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
|
||||
integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
|
||||
|
||||
sshpk@^1.7.0:
|
||||
version "1.18.0"
|
||||
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028"
|
||||
integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==
|
||||
dependencies:
|
||||
asn1 "~0.2.3"
|
||||
assert-plus "^1.0.0"
|
||||
bcrypt-pbkdf "^1.0.0"
|
||||
dashdash "^1.12.0"
|
||||
ecc-jsbn "~0.1.1"
|
||||
getpass "^0.1.1"
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.0.2"
|
||||
tweetnacl "~0.14.0"
|
||||
|
||||
"statuses@>= 1.5.0 < 2", statuses@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
|
||||
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
|
||||
|
||||
stealthy-require@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
|
||||
integrity sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==
|
||||
|
||||
through@~2.3:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
||||
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
|
||||
|
||||
toidentifier@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
|
||||
tough-cookie@^2.3.3, tough-cookie@~2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
|
||||
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
|
||||
dependencies:
|
||||
psl "^1.1.28"
|
||||
punycode "^2.1.1"
|
||||
|
||||
tslib@^2.0.1:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tsscmp@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
|
||||
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
|
||||
|
||||
tunnel-agent@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
|
||||
integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
|
||||
version "0.14.5"
|
||||
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
||||
integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
|
||||
|
||||
type-is@^1.6.16:
|
||||
version "1.6.18"
|
||||
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
||||
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||
dependencies:
|
||||
media-typer "0.3.0"
|
||||
mime-types "~2.1.24"
|
||||
|
||||
universalify@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^3.3.2:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
vary@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||
|
||||
verror@1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
|
||||
integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
core-util-is "1.0.2"
|
||||
extsprintf "^1.2.0"
|
||||
|
||||
ylru@^1.2.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.4.0.tgz#0cf0aa57e9c24f8a2cbde0cc1ca2c9592ac4e0f6"
|
||||
integrity sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==
|
503
src/app.tsx
Normal file
@ -0,0 +1,503 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration } from "react-router-dom";
|
||||
import { Spinner } from "@chakra-ui/react";
|
||||
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import Layout from "./components/layout";
|
||||
import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
|
||||
import useSetColorMode from "./hooks/use-set-color-mode";
|
||||
import { RouteProviders } from "./providers/route";
|
||||
import RequireCurrentAccount from "./providers/route/require-current-account";
|
||||
import GlobalStyles from "./styles";
|
||||
|
||||
import HomeView from "./views/home/index";
|
||||
const DiscoveryHomeView = lazy(() => import("./views/discovery/index"));
|
||||
const DVMFeedView = lazy(() => import("./views/discovery/dvm-feed/feed"));
|
||||
const BlindspotHomeView = lazy(() => import("./views/discovery/blindspot"));
|
||||
const BlindspotFeedView = lazy(() => import("./views/discovery/blindspot/feed"));
|
||||
import SettingsView from "./views/settings";
|
||||
import NostrLinkView from "./views/link";
|
||||
import ProfileView from "./views/profile";
|
||||
const HashTagView = lazy(() => import("./views/hashtag"));
|
||||
import ThreadView from "./views/thread";
|
||||
import NotificationsView from "./views/notifications";
|
||||
import ThreadsNotificationsView from "./views/notifications/threads";
|
||||
const DirectMessagesView = lazy(() => import("./views/dms"));
|
||||
const DirectMessageChatView = lazy(() => import("./views/dms/chat"));
|
||||
|
||||
import SigninView from "./views/signin";
|
||||
import SignupView from "./views/signup";
|
||||
import LoginStartView from "./views/signin/start";
|
||||
import LoginNpubView from "./views/signin/pubkey";
|
||||
import LoginNsecView from "./views/signin/nsec";
|
||||
import LoginNostrConnectView from "./views/signin/nostr-connect";
|
||||
import LoginNostrAddressView from "./views/signin/address";
|
||||
import LoginNostrAddressCreate from "./views/signin/address/create";
|
||||
|
||||
import UserView from "./views/user";
|
||||
import UserNotesTab from "./views/user/notes";
|
||||
import UserFollowersTab from "./views/user/followers";
|
||||
import UserRelaysTab from "./views/user/relays";
|
||||
import UserFollowingTab from "./views/user/following";
|
||||
import UserZapsTab from "./views/user/zaps";
|
||||
import UserReportsTab from "./views/user/reports";
|
||||
import UserAboutTab from "./views/user/about";
|
||||
import UserReactionsTab from "./views/user/reactions";
|
||||
import UserListsTab from "./views/user/lists";
|
||||
import UserGoalsTab from "./views/user/goals";
|
||||
import MutedByView from "./views/user/muted-by";
|
||||
import UserArticlesTab from "./views/user/articles";
|
||||
import UserDMsTab from "./views/user/dms";
|
||||
const UserTorrentsTab = lazy(() => import("./views/user/torrents"));
|
||||
|
||||
import ListsHomeView from "./views/lists";
|
||||
import ListView from "./views/lists/list";
|
||||
import BrowseListView from "./views/lists/browse";
|
||||
|
||||
const EmojiPacksBrowseView = lazy(() => import("./views/emoji-packs/browse"));
|
||||
const EmojiPackView = lazy(() => import("./views/emoji-packs/emoji-pack"));
|
||||
const UserEmojiPacksTab = lazy(() => import("./views/user/emoji-packs"));
|
||||
const EmojiPacksView = lazy(() => import("./views/emoji-packs"));
|
||||
|
||||
const GoalsView = lazy(() => import("./views/goals"));
|
||||
const GoalsBrowseView = lazy(() => import("./views/goals/browse"));
|
||||
const GoalDetailsView = lazy(() => import("./views/goals/goal-details"));
|
||||
|
||||
const BadgesView = lazy(() => import("./views/badges"));
|
||||
const BadgesBrowseView = lazy(() => import("./views/badges/browse"));
|
||||
const BadgeDetailsView = lazy(() => import("./views/badges/badge-details"));
|
||||
|
||||
const CommunitiesHomeView = lazy(() => import("./views/communities"));
|
||||
const CommunitiesExploreView = lazy(() => import("./views/communities/explore"));
|
||||
const CommunityFindByNameView = lazy(() => import("./views/community/find-by-name"));
|
||||
const CommunityView = lazy(() => import("./views/community/index"));
|
||||
const CommunityPendingView = lazy(() => import("./views/community/views/pending"));
|
||||
const CommunityNewestView = lazy(() => import("./views/community/views/newest"));
|
||||
const CommunityTrendingView = lazy(() => import("./views/community/views/trending"));
|
||||
|
||||
import RelaysView from "./views/relays";
|
||||
import RelayView from "./views/relays/relay";
|
||||
import BrowseRelaySetsView from "./views/relays/browse-sets";
|
||||
import CacheRelayView from "./views/relays/cache";
|
||||
import RelaySetView from "./views/relays/relay-set";
|
||||
import AppRelays from "./views/relays/app";
|
||||
import MailboxesView from "./views/relays/mailboxes";
|
||||
import MediaServersView from "./views/relays/media-servers";
|
||||
import NIP05RelaysView from "./views/relays/nip05";
|
||||
import DatabaseView from "./views/relays/cache/database";
|
||||
import ContactListRelaysView from "./views/relays/contact-list";
|
||||
const WebRtcRelaysView = lazy(() => import("./views/relays/webrtc"));
|
||||
const WebRtcConnectView = lazy(() => import("./views/relays/webrtc/connect"));
|
||||
const WebRtcPairView = lazy(() => import("./views/relays/webrtc/pair"));
|
||||
|
||||
import OtherStuffView from "./views/other-stuff";
|
||||
import LaunchpadView from "./views/launchpad";
|
||||
const VideosView = lazy(() => import("./views/videos"));
|
||||
const VideoDetailsView = lazy(() => import("./views/videos/video"));
|
||||
import BookmarksView from "./views/bookmarks";
|
||||
import TaskManagerProvider from "./views/task-manager/provider";
|
||||
import SearchRelaysView from "./views/relays/search";
|
||||
import DisplaySettings from "./views/settings/display";
|
||||
import LightningSettings from "./views/settings/lightning";
|
||||
import PerformanceSettings from "./views/settings/performance";
|
||||
import PrivacySettings from "./views/settings/privacy";
|
||||
import PostSettings from "./views/settings/post";
|
||||
import AccountSettings from "./views/settings/accounts";
|
||||
import ArticlesHomeView from "./views/articles";
|
||||
import ArticleView from "./views/articles/article";
|
||||
const TracksView = lazy(() => import("./views/tracks"));
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
|
||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||
const NetworkMuteGraphView = lazy(() => import("./views/tools/network-mute-graph"));
|
||||
const NetworkDMGraphView = lazy(() => import("./views/tools/network-dm-graph"));
|
||||
const UnknownTimelineView = lazy(() => import("./views/tools/unknown-event-feed"));
|
||||
const EventConsoleView = lazy(() => import("./views/tools/event-console"));
|
||||
const EventPublisherView = lazy(() => import("./views/tools/event-publisher"));
|
||||
const DMTimelineView = lazy(() => import("./views/tools/dm-timeline"));
|
||||
const TransformNoteView = lazy(() => import("./views/tools/transform-note"));
|
||||
const SatelliteCDNView = lazy(() => import("./views/tools/satellite-cdn"));
|
||||
const CorrectionsFeedView = lazy(() => import("./views/tools/corrections"));
|
||||
|
||||
const UserStreamsTab = lazy(() => import("./views/user/streams"));
|
||||
const StreamsView = lazy(() => import("./views/streams"));
|
||||
const StreamView = lazy(() => import("./views/streams/stream"));
|
||||
const StreamModerationView = lazy(() => import("./views/streams/dashboard"));
|
||||
|
||||
const SearchView = lazy(() => import("./views/search"));
|
||||
const MapView = lazy(() => import("./views/map"));
|
||||
|
||||
const ChannelsHomeView = lazy(() => import("./views/channels"));
|
||||
const ChannelView = lazy(() => import("./views/channels/channel"));
|
||||
|
||||
const TorrentsView = lazy(() => import("./views/torrents"));
|
||||
const TorrentDetailsView = lazy(() => import("./views/torrents/torrent"));
|
||||
const NewTorrentView = lazy(() => import("./views/torrents/new"));
|
||||
|
||||
const WikiHomeView = lazy(() => import("./views/wiki"));
|
||||
const WikiPageView = lazy(() => import("./views/wiki/page"));
|
||||
const WikiTopicView = lazy(() => import("./views/wiki/topic"));
|
||||
const WikiSearchView = lazy(() => import("./views/wiki/search"));
|
||||
const WikiCompareView = lazy(() => import("./views/wiki/compare"));
|
||||
const CreateWikiPageView = lazy(() => import("./views/wiki/create"));
|
||||
const EditWikiPageView = lazy(() => import("./views/wiki/edit"));
|
||||
|
||||
const RootPage = () => {
|
||||
useSetColorMode();
|
||||
|
||||
return (
|
||||
<RouteProviders>
|
||||
<Layout>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</RouteProviders>
|
||||
);
|
||||
};
|
||||
const NoLayoutPage = () => {
|
||||
return (
|
||||
<RouteProviders>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</RouteProviders>
|
||||
);
|
||||
};
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "signin",
|
||||
element: <SigninView />,
|
||||
children: [
|
||||
{ path: "", element: <LoginStartView /> },
|
||||
{ path: "npub", element: <LoginNpubView /> },
|
||||
{ path: "nsec", element: <LoginNsecView /> },
|
||||
{
|
||||
path: "address",
|
||||
children: [
|
||||
{ path: "", element: <LoginNostrAddressView /> },
|
||||
{ path: "create", element: <LoginNostrAddressCreate /> },
|
||||
],
|
||||
},
|
||||
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "signup",
|
||||
element: <NoLayoutPage />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <SignupView />,
|
||||
},
|
||||
{
|
||||
path: ":step",
|
||||
element: <SignupView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "streams/moderation",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<StreamModerationView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "streams/:naddr",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<StreamView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "map",
|
||||
element: <MapView />,
|
||||
},
|
||||
{
|
||||
path: "launchpad",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<LaunchpadView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <RootPage />,
|
||||
children: [
|
||||
{
|
||||
path: "/u/:pubkey",
|
||||
element: <UserView />,
|
||||
children: [
|
||||
{ path: "", element: <UserAboutTab /> },
|
||||
{ path: "about", element: <UserAboutTab /> },
|
||||
{ path: "notes", element: <UserNotesTab /> },
|
||||
{ path: "articles", element: <UserArticlesTab /> },
|
||||
{ path: "streams", element: <UserStreamsTab /> },
|
||||
{ path: "tracks", element: <UserTracksTab /> },
|
||||
{ path: "videos", element: <UserVideosTab /> },
|
||||
{ path: "zaps", element: <UserZapsTab /> },
|
||||
{ path: "likes", element: <UserReactionsTab /> },
|
||||
{ path: "lists", element: <UserListsTab /> },
|
||||
{ path: "followers", element: <UserFollowersTab /> },
|
||||
{ path: "following", element: <UserFollowingTab /> },
|
||||
{ path: "goals", element: <UserGoalsTab /> },
|
||||
{ path: "emojis", element: <UserEmojiPacksTab /> },
|
||||
{ path: "relays", element: <UserRelaysTab /> },
|
||||
{ path: "reports", element: <UserReportsTab /> },
|
||||
{ path: "muted-by", element: <MutedByView /> },
|
||||
{ path: "dms", element: <UserDMsTab /> },
|
||||
{ path: "torrents", element: <UserTorrentsTab /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/n/:id",
|
||||
element: <ThreadView />,
|
||||
},
|
||||
{ path: "other-stuff", element: <OtherStuffView /> },
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsView />,
|
||||
children: [
|
||||
{ path: "", element: <DisplaySettings /> },
|
||||
{ path: "post", element: <PostSettings /> },
|
||||
{
|
||||
path: "accounts",
|
||||
element: (
|
||||
<RequireCurrentAccount>
|
||||
<AccountSettings />
|
||||
</RequireCurrentAccount>
|
||||
),
|
||||
},
|
||||
{ path: "display", element: <DisplaySettings /> },
|
||||
{ path: "privacy", element: <PrivacySettings /> },
|
||||
{ path: "lightning", element: <LightningSettings /> },
|
||||
{ path: "performance", element: <PerformanceSettings /> },
|
||||
{ path: "media-servers", element: <MediaServersView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "relays",
|
||||
element: <RelaysView />,
|
||||
children: [
|
||||
{ path: "", element: <AppRelays /> },
|
||||
{ path: "app", element: <AppRelays /> },
|
||||
{
|
||||
path: "cache",
|
||||
children: [
|
||||
{ path: "database", element: <DatabaseView /> },
|
||||
{ path: "", element: <CacheRelayView /> },
|
||||
],
|
||||
},
|
||||
{ path: "mailboxes", element: <MailboxesView /> },
|
||||
{ path: "search", element: <SearchRelaysView /> },
|
||||
{ path: "media-servers", element: <MediaServersView /> },
|
||||
{ path: "nip05", element: <NIP05RelaysView /> },
|
||||
{ path: "contacts", element: <ContactListRelaysView /> },
|
||||
{
|
||||
path: "webrtc",
|
||||
children: [
|
||||
{ path: "connect", element: <WebRtcConnectView /> },
|
||||
{ path: "pair", element: <WebRtcPairView /> },
|
||||
{ path: "", element: <WebRtcRelaysView /> },
|
||||
],
|
||||
},
|
||||
{ path: "sets", element: <BrowseRelaySetsView /> },
|
||||
{ path: ":id", element: <RelaySetView /> },
|
||||
],
|
||||
},
|
||||
{ path: "r/:relay", element: <RelayView /> },
|
||||
{
|
||||
path: "notifications",
|
||||
children: [
|
||||
{ path: "threads", element: <ThreadsNotificationsView /> },
|
||||
{ path: "", element: <NotificationsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "videos",
|
||||
children: [
|
||||
{
|
||||
path: ":naddr",
|
||||
element: <VideoDetailsView />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <VideosView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "wiki",
|
||||
children: [
|
||||
{ path: "search", element: <WikiSearchView /> },
|
||||
{ path: "topic/:topic", element: <WikiTopicView /> },
|
||||
{ path: "page/:naddr", element: <WikiPageView /> },
|
||||
{ path: "edit/:topic", element: <EditWikiPageView /> },
|
||||
{ path: "compare/:topic/:a/:b", element: <WikiCompareView /> },
|
||||
{ path: "create", element: <CreateWikiPageView /> },
|
||||
{ path: "", element: <WikiHomeView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "discovery",
|
||||
children: [
|
||||
{ path: "", element: <DiscoveryHomeView /> },
|
||||
{ path: "dvm/:addr", element: <DVMFeedView /> },
|
||||
{
|
||||
path: "blindspot",
|
||||
element: (
|
||||
<RequireCurrentAccount>
|
||||
<Outlet />
|
||||
</RequireCurrentAccount>
|
||||
),
|
||||
children: [
|
||||
{ path: "", element: <BlindspotHomeView /> },
|
||||
{ path: ":pubkey", element: <BlindspotFeedView /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "search", element: <SearchView /> },
|
||||
{
|
||||
path: "dm",
|
||||
element: <DirectMessagesView />,
|
||||
children: [{ path: ":pubkey", element: <DirectMessageChatView /> }],
|
||||
},
|
||||
{ path: "profile", element: <ProfileView /> },
|
||||
{
|
||||
path: "tools",
|
||||
children: [
|
||||
{ path: "", element: <ToolsHomeView /> },
|
||||
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
|
||||
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
||||
{ path: "dm-timeline", element: <DMTimelineView /> },
|
||||
{ path: "transform/:id", element: <TransformNoteView /> },
|
||||
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
|
||||
{ path: "unknown", element: <UnknownTimelineView /> },
|
||||
{ path: "console", element: <EventConsoleView /> },
|
||||
{ path: "publisher", element: <EventPublisherView /> },
|
||||
{ path: "corrections", element: <CorrectionsFeedView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "lists",
|
||||
children: [
|
||||
{ path: "", element: <ListsHomeView /> },
|
||||
{ path: "browse", element: <BrowseListView /> },
|
||||
{ path: ":addr", element: <ListView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "bookmarks",
|
||||
children: [
|
||||
{ path: ":pubkey", element: <BookmarksView /> },
|
||||
{ path: "", element: <BookmarksView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "communities",
|
||||
children: [
|
||||
{ path: "", element: <CommunitiesHomeView /> },
|
||||
{ path: "explore", element: <CommunitiesExploreView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "articles",
|
||||
children: [
|
||||
{ path: "", element: <ArticlesHomeView /> },
|
||||
{ path: ":naddr", element: <ArticleView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "c/:community",
|
||||
children: [
|
||||
{ path: "", element: <CommunityFindByNameView /> },
|
||||
{
|
||||
path: ":pubkey",
|
||||
element: <CommunityView />,
|
||||
children: [
|
||||
{ path: "", element: <CommunityNewestView /> },
|
||||
{ path: "trending", element: <CommunityTrendingView /> },
|
||||
{ path: "newest", element: <CommunityNewestView /> },
|
||||
{ path: "pending", element: <CommunityPendingView /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "torrents",
|
||||
children: [
|
||||
{ path: "", element: <TorrentsView /> },
|
||||
{ path: "new", element: <NewTorrentView /> },
|
||||
{ path: ":id", element: <TorrentDetailsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "channels",
|
||||
children: [
|
||||
{ path: "", element: <ChannelsHomeView /> },
|
||||
{ path: ":id", element: <ChannelView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "goals",
|
||||
children: [
|
||||
{ path: "", element: <GoalsView /> },
|
||||
{ path: "browse", element: <GoalsBrowseView /> },
|
||||
{ path: ":id", element: <GoalDetailsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "badges",
|
||||
children: [
|
||||
{ path: "", element: <BadgesView /> },
|
||||
{ path: "browse", element: <BadgesBrowseView /> },
|
||||
{ path: ":naddr", element: <BadgeDetailsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "emojis",
|
||||
children: [
|
||||
{ path: "", element: <EmojiPacksView /> },
|
||||
{ path: "browse", element: <EmojiPacksBrowseView /> },
|
||||
{ path: ":addr", element: <EmojiPackView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "streams",
|
||||
element: <StreamsView />,
|
||||
},
|
||||
{
|
||||
path: "tracks",
|
||||
element: <TracksView />,
|
||||
},
|
||||
{ path: "l/:link", element: <NostrLinkView /> },
|
||||
{ path: "t/:hashtag", element: <HashTagView /> },
|
||||
{
|
||||
path: "",
|
||||
element: <HomeView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export const App = () => (
|
||||
<ErrorBoundary>
|
||||
<GlobalStyles />
|
||||
<TaskManagerProvider parentRouter={router}>
|
||||
<DrawerSubViewProvider parentRouter={router}>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</DrawerSubViewProvider>
|
||||
</TaskManagerProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
35
src/classes/accounts/account.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { AppSettings } from "../../services/settings/migrations";
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
|
||||
export class Account {
|
||||
readonly type: string = "unknown";
|
||||
pubkey: string;
|
||||
localSettings?: AppSettings;
|
||||
|
||||
protected _signer?: Nip07Signer | undefined;
|
||||
public get signer(): Nip07Signer | undefined {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: Nip07Signer | undefined) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return !this.signer;
|
||||
}
|
||||
|
||||
constructor(pubkey: string) {
|
||||
this.pubkey = pubkey;
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return { type: this.type, pubkey: this.pubkey, localSettings: this.localSettings };
|
||||
}
|
||||
fromJSON(data: any): this {
|
||||
this.pubkey = data.pubkey;
|
||||
if (data.localSettings) {
|
||||
this.localSettings = data.localSettings;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
19
src/classes/accounts/amber-account.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import AmberSigner from "../signers/amber-signer";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class AmberAccount extends Account {
|
||||
readonly type = "amber";
|
||||
|
||||
protected declare _signer?: AmberSigner | undefined;
|
||||
public get signer(): AmberSigner | undefined {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: AmberSigner | undefined) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
constructor(pubkey: string) {
|
||||
super(pubkey);
|
||||
this.signer = new AmberSigner();
|
||||
}
|
||||
}
|
17
src/classes/accounts/extension-account.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class ExtensionAccount extends Account {
|
||||
readonly type = "extension";
|
||||
|
||||
public get signer(): Nip07Signer | undefined {
|
||||
return window.nostr;
|
||||
}
|
||||
set signer(signer: Nip07Signer) {
|
||||
throw new Error("Cant update signer");
|
||||
}
|
||||
|
||||
fromJSON(data: any): this {
|
||||
return super.fromJSON(data);
|
||||
}
|
||||
}
|
38
src/classes/accounts/nostr-connect-account.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { DEFAULT_NOSTR_CONNECT_RELAYS } from "../../const";
|
||||
import nostrConnectService from "../../services/nostr-connect";
|
||||
import NostrConnectSigner from "../signers/nostr-connect-signer";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class NostrConnectAccount extends Account {
|
||||
readonly type = "nostr-connect";
|
||||
|
||||
protected declare _signer: NostrConnectSigner;
|
||||
public get signer(): NostrConnectSigner {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: NostrConnectSigner) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
constructor(pubkey: string, signer?: NostrConnectSigner) {
|
||||
super(pubkey);
|
||||
this.signer = signer || nostrConnectService.createSigner(pubkey, DEFAULT_NOSTR_CONNECT_RELAYS);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
signerRelays: this.signer.relays,
|
||||
clientSecretKey: this.signer.secretKey,
|
||||
};
|
||||
}
|
||||
fromJSON(data: any): this {
|
||||
super.fromJSON(data);
|
||||
this.signer = nostrConnectService.createSigner(data.pubkey, data.signerRelays, data.clientSecretKey);
|
||||
|
||||
// presume the client has already connected
|
||||
nostrConnectService.saveSigner(data.pubKey);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
48
src/classes/accounts/nsec-account.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import SimpleSigner from "../signers/simple-signer";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class NsecAccount extends Account {
|
||||
readonly type = "nsec";
|
||||
|
||||
protected declare _signer?: SimpleSigner | undefined;
|
||||
public get signer(): SimpleSigner | undefined {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: SimpleSigner | undefined) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
constructor(pubkey: string) {
|
||||
super(pubkey);
|
||||
}
|
||||
|
||||
static newKey() {
|
||||
const key = generateSecretKey();
|
||||
const account = new NsecAccount(getPublicKey(key));
|
||||
account.signer = new SimpleSigner(key);
|
||||
return account;
|
||||
}
|
||||
|
||||
static fromKey(key: Uint8Array) {
|
||||
const account = new NsecAccount(getPublicKey(key));
|
||||
account.signer = new SimpleSigner(key);
|
||||
return account;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
nsec: this.signer && nip19.nsecEncode(this.signer.key),
|
||||
};
|
||||
}
|
||||
|
||||
fromJSON(data: any): this {
|
||||
const parse = nip19.decode(data.nsec);
|
||||
|
||||
if (parse.type !== "nsec") throw new Error("Unknown nsec type");
|
||||
this.signer = new SimpleSigner(parse.data as Uint8Array);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
43
src/classes/accounts/password-account.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import PasswordSigner from "../signers/password-signer";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class PasswordAccount extends Account {
|
||||
readonly type = "local";
|
||||
|
||||
protected declare _signer: PasswordSigner;
|
||||
public get signer(): PasswordSigner {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: PasswordSigner) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
constructor(pubkey: string) {
|
||||
super(pubkey);
|
||||
this.signer = new PasswordSigner();
|
||||
}
|
||||
|
||||
static fromNcryptsec(pubkey: string, ncryptsec: string) {
|
||||
const account = new PasswordAccount(pubkey);
|
||||
account.pubkey = pubkey;
|
||||
return account.fromJSON({ ncryptsec });
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
if (this.signer.ncryptsec) {
|
||||
return { ...super.toJSON(), ncryptsec: this.signer.ncryptsec };
|
||||
} else
|
||||
return { ...super.toJSON(), secKey: this.signer.buffer, iv: this.signer.iv, ncryptsec: this.signer.ncryptsec };
|
||||
}
|
||||
fromJSON(data: any): this {
|
||||
this.signer = new PasswordSigner();
|
||||
if (data.ncryptsec) {
|
||||
this.signer.ncryptsec = data.ncryptsec as string;
|
||||
} else if (data.secKey && data.iv) {
|
||||
this.signer.buffer = data.secKey as ArrayBuffer;
|
||||
this.signer.iv = data.iv as Uint8Array;
|
||||
}
|
||||
|
||||
return super.fromJSON(data);
|
||||
}
|
||||
}
|
9
src/classes/accounts/pubkey-account.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class PubkeyAccount extends Account {
|
||||
readonly type = "pubkey";
|
||||
|
||||
constructor(pubkey: string) {
|
||||
super(pubkey);
|
||||
}
|
||||
}
|
18
src/classes/accounts/serial-port-account.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import SerialPortSigner from "../signers/serial-port-signer";
|
||||
import { Account } from "./account";
|
||||
|
||||
export default class SerialPortAccount extends Account {
|
||||
readonly type = "serial";
|
||||
protected declare _signer: SerialPortSigner;
|
||||
public get signer(): SerialPortSigner {
|
||||
return this._signer;
|
||||
}
|
||||
public set signer(value: SerialPortSigner) {
|
||||
this._signer = value;
|
||||
}
|
||||
|
||||
constructor(pubkey: string) {
|
||||
super(pubkey);
|
||||
this.signer = new SerialPortSigner();
|
||||
}
|
||||
}
|
122
src/classes/batch-event-loader.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import _throttle from "lodash.throttle";
|
||||
import debug, { Debugger } from "debug";
|
||||
|
||||
import EventStore from "./event-store";
|
||||
import PersistentSubscription from "./persistent-subscription";
|
||||
import Process from "./process";
|
||||
import BracketsX from "../components/icons/brackets-x";
|
||||
import processManager from "../services/process-manager";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
|
||||
/** This class is used to batch requests for single events from a relay */
|
||||
export default class BatchEventLoader {
|
||||
events = new EventStore();
|
||||
relay: AbstractRelay;
|
||||
process: Process;
|
||||
|
||||
subscription: PersistentSubscription;
|
||||
|
||||
// a map of events that are waiting for the current request to finish
|
||||
private next = new Map<string, Deferred<NostrEvent | null>>();
|
||||
|
||||
// a map of events currently being requested from the relay
|
||||
private pending = new Map<string, Deferred<NostrEvent | null>>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: AbstractRelay, log?: Debugger) {
|
||||
this.relay = relay;
|
||||
this.log = log || debug("BatchEventLoader");
|
||||
this.process = new Process("BatchEventLoader", this, [relay]);
|
||||
this.process.icon = BracketsX;
|
||||
processManager.registerProcess(this.process);
|
||||
|
||||
this.subscription = new PersistentSubscription(this.relay, {
|
||||
onevent: (event) => this.handleEvent(event),
|
||||
oneose: () => this.handleEOSE(),
|
||||
});
|
||||
this.process.addChild(this.subscription.process);
|
||||
}
|
||||
|
||||
requestEvent(id: string): Promise<NostrEvent | null> {
|
||||
const event = this.events.getEvent(id);
|
||||
|
||||
if (!event) {
|
||||
if (this.pending.has(id)) return this.pending.get(id)!;
|
||||
if (this.next.has(id)) return this.next.get(id)!;
|
||||
|
||||
const defer = createDefer<NostrEvent | null>();
|
||||
this.next.set(id, defer);
|
||||
|
||||
// request subscription update
|
||||
this.start();
|
||||
|
||||
return defer;
|
||||
}
|
||||
|
||||
return Promise.resolve(event);
|
||||
}
|
||||
|
||||
start = _throttle(
|
||||
() => {
|
||||
// don't do anything if the subscription is already running
|
||||
if (this.process.active) return;
|
||||
|
||||
this.process.active = true;
|
||||
this.update();
|
||||
},
|
||||
500,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
const key = event.id;
|
||||
|
||||
this.events.addEvent(event);
|
||||
this.pending.get(key)?.resolve(event);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
private handleEOSE() {
|
||||
// resolve with null for any events we where not able to find
|
||||
for (const [key, defer] of this.pending) defer.resolve(null);
|
||||
|
||||
// reset
|
||||
this.pending.clear();
|
||||
this.process.active = false;
|
||||
|
||||
// do next request or close the subscription
|
||||
this.start();
|
||||
}
|
||||
|
||||
async update() {
|
||||
// copy everything from next to pending
|
||||
for (const [key, defer] of this.next) this.pending.set(key, defer);
|
||||
this.next.clear();
|
||||
|
||||
// update subscription
|
||||
if (this.pending.size > 0) {
|
||||
this.log(`Updating filters ${this.pending.size} events`);
|
||||
|
||||
try {
|
||||
this.process.active = true;
|
||||
this.subscription.filters = [{ ids: Array.from(this.pending.keys()) }];
|
||||
await this.subscription.update();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.log(`Failed to update subscription`, error.message);
|
||||
this.process.active = false;
|
||||
}
|
||||
} else {
|
||||
this.log("Closing");
|
||||
this.subscription.close();
|
||||
this.process.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.subscription.destroy();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
152
src/classes/batch-identifier-loader.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import _throttle from "lodash.throttle";
|
||||
import debug, { Debugger } from "debug";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
|
||||
import PersistentSubscription from "./persistent-subscription";
|
||||
import Process from "./process";
|
||||
import processManager from "../services/process-manager";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
import Dataflow04 from "../components/icons/dataflow-04";
|
||||
import SuperMap from "./super-map";
|
||||
import Subject from "./subject";
|
||||
|
||||
/** Batches requests for events with #d tags from a single relay */
|
||||
export default class BatchIdentifierLoader {
|
||||
kinds: number[];
|
||||
relay: AbstractRelay;
|
||||
process: Process;
|
||||
|
||||
/** list of identifiers that have been loaded */
|
||||
requested = new Set<string>();
|
||||
/** identifier -> event uid -> event */
|
||||
identifiers = new SuperMap<string, Map<string, NostrEvent>>(() => new Map());
|
||||
|
||||
onIdentifierUpdate = new Subject<string>();
|
||||
|
||||
subscription: PersistentSubscription;
|
||||
|
||||
// a map of identifiers that are waiting for the current request to finish
|
||||
private next = new Map<string, Deferred<Map<string, NostrEvent>>>();
|
||||
|
||||
// a map of identifiers currently being requested from the relay
|
||||
private pending = new Map<string, Deferred<Map<string, NostrEvent>>>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: AbstractRelay, kinds: number[], log?: Debugger) {
|
||||
this.relay = relay;
|
||||
this.kinds = kinds;
|
||||
this.log = log || debug("BatchIdentifierLoader");
|
||||
this.process = new Process("BatchIdentifierLoader", this, [relay]);
|
||||
this.process.icon = Dataflow04;
|
||||
processManager.registerProcess(this.process);
|
||||
|
||||
this.subscription = new PersistentSubscription(this.relay, {
|
||||
onevent: (event) => this.handleEvent(event),
|
||||
oneose: () => this.handleEOSE(),
|
||||
});
|
||||
this.process.addChild(this.subscription.process);
|
||||
}
|
||||
|
||||
requestEvents(identifier: string): Promise<Map<string, NostrEvent>> {
|
||||
// if there is a cache only return it if we have requested this id before
|
||||
if (this.identifiers.has(identifier) && this.requested.has(identifier)) {
|
||||
return Promise.resolve(this.identifiers.get(identifier));
|
||||
}
|
||||
|
||||
if (this.pending.has(identifier)) return this.pending.get(identifier)!;
|
||||
if (this.next.has(identifier)) return this.next.get(identifier)!;
|
||||
|
||||
const defer = createDefer<Map<string, NostrEvent>>();
|
||||
this.next.set(identifier, defer);
|
||||
|
||||
// request subscription update
|
||||
this.requestUpdate();
|
||||
|
||||
return defer;
|
||||
}
|
||||
|
||||
requestUpdate = _throttle(
|
||||
() => {
|
||||
// don't do anything if the subscription is already running
|
||||
if (this.process.active) return;
|
||||
|
||||
this.process.active = true;
|
||||
this.update();
|
||||
},
|
||||
500,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
// add event to cache
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "d" && tag[1]) {
|
||||
const identifier = tag[1];
|
||||
this.identifiers.get(identifier).set(getEventUID(event), event);
|
||||
this.changedIdentifiers.add(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private changedIdentifiers = new Set<string>();
|
||||
handleEOSE() {
|
||||
// resolve all pending from the last request
|
||||
for (const [identifier, defer] of this.pending) {
|
||||
defer.resolve(this.identifiers.get(identifier));
|
||||
this.changedIdentifiers.add(identifier);
|
||||
}
|
||||
|
||||
// reset
|
||||
this.pending.clear();
|
||||
this.process.active = false;
|
||||
|
||||
for (const identifier of this.changedIdentifiers) {
|
||||
this.onIdentifierUpdate.next(identifier);
|
||||
}
|
||||
|
||||
// do next request or close the subscription
|
||||
if (this.next.size > 0) this.requestUpdate();
|
||||
}
|
||||
|
||||
async update() {
|
||||
// copy everything from next to pending
|
||||
for (const [identifier, defer] of this.next) this.pending.set(identifier, defer);
|
||||
this.next.clear();
|
||||
|
||||
// update subscription
|
||||
if (this.pending.size > 0) {
|
||||
this.log(`Updating filters ${this.pending.size} events`);
|
||||
|
||||
const dTags: string[] = [];
|
||||
const identifiers = Array.from(this.pending.keys());
|
||||
for (const identifier of identifiers) {
|
||||
this.requested.add(identifier);
|
||||
dTags.push(identifier);
|
||||
}
|
||||
|
||||
try {
|
||||
this.process.active = true;
|
||||
this.subscription.filters = [];
|
||||
if (dTags.length > 0) this.subscription.filters.push({ "#d": dTags, kinds: this.kinds });
|
||||
|
||||
await this.subscription.update();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.log(`Failed to update subscription`, error.message);
|
||||
this.process.active = false;
|
||||
}
|
||||
} else {
|
||||
this.log("Closing");
|
||||
this.subscription.close();
|
||||
this.process.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.subscription.destroy();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
155
src/classes/batch-kind-pubkey-loader.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { Filter, NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import _throttle from "lodash.throttle";
|
||||
import debug, { Debugger } from "debug";
|
||||
|
||||
import EventStore from "./event-store";
|
||||
import { getEventUID } from "../helpers/nostr/event";
|
||||
import PersistentSubscription from "./persistent-subscription";
|
||||
import Process from "./process";
|
||||
import BracketsX from "../components/icons/brackets-x";
|
||||
import processManager from "../services/process-manager";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
|
||||
export function createCoordinate(kind: number, pubkey: string, d?: string) {
|
||||
return `${kind}:${pubkey}${d ? ":" + d : ""}`;
|
||||
}
|
||||
|
||||
/** This class is used to batch requests by kind and pubkey to a single relay */
|
||||
export default class BatchKindPubkeyLoader {
|
||||
events = new EventStore();
|
||||
relay: AbstractRelay;
|
||||
process: Process;
|
||||
|
||||
subscription: PersistentSubscription;
|
||||
|
||||
// a map of events that are waiting for the current request to finish
|
||||
private next = new Map<string, Deferred<NostrEvent | null>>();
|
||||
|
||||
// a map of events currently being requested from the relay
|
||||
private pending = new Map<string, Deferred<NostrEvent | null>>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: AbstractRelay, log?: Debugger) {
|
||||
this.relay = relay;
|
||||
this.log = log || debug("BatchKindPubkeyLoader");
|
||||
this.process = new Process("BatchKindPubkeyLoader", this, [relay]);
|
||||
this.process.icon = BracketsX;
|
||||
processManager.registerProcess(this.process);
|
||||
|
||||
this.subscription = new PersistentSubscription(this.relay, {
|
||||
onevent: (event) => this.handleEvent(event),
|
||||
oneose: () => this.handleEOSE(),
|
||||
});
|
||||
this.process.addChild(this.subscription.process);
|
||||
}
|
||||
|
||||
requestEvent(kind: number, pubkey: string, d?: string): Promise<NostrEvent | null> {
|
||||
const key = createCoordinate(kind, pubkey, d);
|
||||
const event = this.events.getEvent(key);
|
||||
|
||||
if (!event) {
|
||||
if (this.pending.has(key)) return this.pending.get(key)!;
|
||||
if (this.next.has(key)) return this.next.get(key)!;
|
||||
|
||||
const defer = createDefer<NostrEvent | null>();
|
||||
this.next.set(key, defer);
|
||||
|
||||
// request subscription update
|
||||
this.start();
|
||||
|
||||
return defer;
|
||||
}
|
||||
|
||||
return Promise.resolve(event);
|
||||
}
|
||||
|
||||
start = _throttle(
|
||||
() => {
|
||||
// don't do anything if the subscription is already running
|
||||
if (this.process.active) return;
|
||||
|
||||
this.process.active = true;
|
||||
this.update();
|
||||
},
|
||||
500,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
const key = getEventUID(event);
|
||||
|
||||
const defer = this.pending.get(key);
|
||||
if (defer) this.pending.delete(key);
|
||||
|
||||
const current = this.events.getEvent(key);
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
this.events.addEvent(event);
|
||||
|
||||
if (defer) defer.resolve(event);
|
||||
} else if (defer) defer.resolve(null);
|
||||
}
|
||||
private handleEOSE() {
|
||||
// resolve with null for any events we where not able to find
|
||||
for (const [key, defer] of this.pending) defer.resolve(null);
|
||||
|
||||
// reset
|
||||
this.pending.clear();
|
||||
this.process.active = false;
|
||||
|
||||
// batch finished, if there is a next request an update
|
||||
if (this.next.size > 0) this.start();
|
||||
}
|
||||
|
||||
async update() {
|
||||
// copy everything from next to pending
|
||||
for (const [key, defer] of this.next) this.pending.set(key, defer);
|
||||
this.next.clear();
|
||||
|
||||
// update subscription
|
||||
if (this.pending.size > 0) {
|
||||
const filters: Record<number, Filter> = {};
|
||||
|
||||
for (const [cord] of this.pending) {
|
||||
const [kindStr, pubkey, d] = cord.split(":") as [string, string] | [string, string, string];
|
||||
const kind = parseInt(kindStr);
|
||||
filters[kind] = filters[kind] || { kinds: [kind] };
|
||||
|
||||
const arr = (filters[kind].authors = filters[kind].authors || []);
|
||||
arr.push(pubkey);
|
||||
|
||||
if (d) {
|
||||
const arr = (filters[kind]["#d"] = filters[kind]["#d"] || []);
|
||||
arr.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Updating query`,
|
||||
Array.from(Object.keys(filters))
|
||||
.map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`)
|
||||
.join(", "),
|
||||
);
|
||||
|
||||
try {
|
||||
this.process.active = true;
|
||||
this.subscription.filters = Array.from(Object.values(filters));
|
||||
await this.subscription.update();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.log(`Subscription failed to update`, error.message);
|
||||
this.process.active = false;
|
||||
}
|
||||
} else {
|
||||
this.log("Closing");
|
||||
this.subscription.close();
|
||||
this.process.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.subscription.destroy();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
154
src/classes/batch-relation-loader.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import _throttle from "lodash.throttle";
|
||||
import debug, { Debugger } from "debug";
|
||||
|
||||
import PersistentSubscription from "./persistent-subscription";
|
||||
import Process from "./process";
|
||||
import processManager from "../services/process-manager";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
import Dataflow04 from "../components/icons/dataflow-04";
|
||||
import SuperMap from "./super-map";
|
||||
import Subject from "./subject";
|
||||
|
||||
/** Batches requests for events that reference another event (via #e tag) from a single relay */
|
||||
export default class BatchRelationLoader {
|
||||
kinds: number[];
|
||||
relay: AbstractRelay;
|
||||
process: Process;
|
||||
|
||||
requested = new Set<string>();
|
||||
/** event id / coordinate -> event id -> event */
|
||||
references = new SuperMap<string, Map<string, NostrEvent>>(() => new Map());
|
||||
|
||||
onEventUpdate = new Subject<string>();
|
||||
|
||||
subscription: PersistentSubscription;
|
||||
|
||||
// a map of events that are waiting for the current request to finish
|
||||
private next = new Map<string, Deferred<Map<string, NostrEvent>>>();
|
||||
|
||||
// a map of events currently being requested from the relay
|
||||
private pending = new Map<string, Deferred<Map<string, NostrEvent>>>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: AbstractRelay, kinds: number[], log?: Debugger) {
|
||||
this.relay = relay;
|
||||
this.kinds = kinds;
|
||||
this.log = log || debug("BatchRelationLoader");
|
||||
this.process = new Process("BatchRelationLoader", this, [relay]);
|
||||
this.process.icon = Dataflow04;
|
||||
processManager.registerProcess(this.process);
|
||||
|
||||
this.subscription = new PersistentSubscription(this.relay, {
|
||||
onevent: (event) => this.handleEvent(event),
|
||||
oneose: () => this.handleEOSE(),
|
||||
});
|
||||
this.process.addChild(this.subscription.process);
|
||||
}
|
||||
|
||||
requestEvents(uid: string): Promise<Map<string, NostrEvent>> {
|
||||
// if there is a cache only return it if we have requested this id before
|
||||
if (this.references.has(uid) && this.requested.has(uid)) {
|
||||
return Promise.resolve(this.references.get(uid));
|
||||
}
|
||||
|
||||
if (this.pending.has(uid)) return this.pending.get(uid)!;
|
||||
if (this.next.has(uid)) return this.next.get(uid)!;
|
||||
|
||||
const defer = createDefer<Map<string, NostrEvent>>();
|
||||
this.next.set(uid, defer);
|
||||
|
||||
// request subscription update
|
||||
this.requestUpdate();
|
||||
|
||||
return defer;
|
||||
}
|
||||
|
||||
requestUpdate = _throttle(
|
||||
() => {
|
||||
// don't do anything if the subscription is already running
|
||||
if (this.process.active) return;
|
||||
|
||||
this.process.active = true;
|
||||
this.update();
|
||||
},
|
||||
500,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
// add event to cache
|
||||
const updateIds = new Set<string>();
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "e" && tag[1]) {
|
||||
const id = tag[1];
|
||||
this.references.get(id).set(event.id, event);
|
||||
updateIds.add(id);
|
||||
} else if (tag[0] === "a" && tag[1]) {
|
||||
const cord = tag[1];
|
||||
this.references.get(cord).set(event.id, event);
|
||||
updateIds.add(cord);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of updateIds) this.onEventUpdate.next(id);
|
||||
}
|
||||
handleEOSE() {
|
||||
// resolve all pending from the last request
|
||||
for (const [uid, defer] of this.pending) {
|
||||
defer.resolve(this.references.get(uid));
|
||||
}
|
||||
|
||||
// reset
|
||||
this.pending.clear();
|
||||
this.process.active = false;
|
||||
|
||||
// do next request or close the subscription
|
||||
if (this.next.size > 0) this.requestUpdate();
|
||||
}
|
||||
|
||||
async update() {
|
||||
// copy everything from next to pending
|
||||
for (const [uid, defer] of this.next) this.pending.set(uid, defer);
|
||||
this.next.clear();
|
||||
|
||||
// update subscription
|
||||
if (this.pending.size > 0) {
|
||||
this.log(`Updating filters ${this.pending.size} events`);
|
||||
|
||||
const ids: string[] = [];
|
||||
const cords: string[] = [];
|
||||
const uids = Array.from(this.pending.keys());
|
||||
for (const uid of uids) {
|
||||
this.requested.add(uid);
|
||||
|
||||
if (uid.includes(":")) cords.push(uid);
|
||||
else ids.push(uid);
|
||||
}
|
||||
|
||||
try {
|
||||
this.process.active = true;
|
||||
this.subscription.filters = [];
|
||||
if (ids.length > 0) this.subscription.filters.push({ "#e": ids, kinds: this.kinds });
|
||||
if (ids.length > 0) this.subscription.filters.push({ "#a": cords, kinds: this.kinds });
|
||||
|
||||
await this.subscription.update();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.log(`Failed to update subscription`, error.message);
|
||||
this.process.active = false;
|
||||
}
|
||||
} else {
|
||||
this.log("Closing");
|
||||
this.subscription.close();
|
||||
this.process.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.subscription.destroy();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
136
src/classes/chunked-request.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { Debugger } from "debug";
|
||||
import { Filter, NostrEvent, matchFilters } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import { SimpleRelay } from "nostr-idb";
|
||||
import _throttle from "lodash.throttle";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import Subject from "./subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
import EventStore from "./event-store";
|
||||
import deleteEventService from "../services/delete-events";
|
||||
import { mergeFilter } from "../helpers/nostr/filter";
|
||||
import { isATag, isETag } from "../types/nostr-event";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import Process from "./process";
|
||||
import processManager from "../services/process-manager";
|
||||
import LayersThree01 from "../components/icons/layers-three-01";
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 100;
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
export default class ChunkedRequest {
|
||||
id: string;
|
||||
process: Process;
|
||||
relay: AbstractRelay;
|
||||
filters: Filter[];
|
||||
chunkSize = DEFAULT_CHUNK_SIZE;
|
||||
private log: Debugger;
|
||||
private subs: ZenObservable.Subscription[] = [];
|
||||
|
||||
loading = false;
|
||||
events: EventStore;
|
||||
/** set to true when the next chunk produces 0 events */
|
||||
complete = false;
|
||||
|
||||
private lastChunkIdx = 0;
|
||||
onChunkFinish = new Subject<number>();
|
||||
|
||||
constructor(relay: SimpleRelay | AbstractRelay, filters: Filter[], log?: Debugger) {
|
||||
this.id = nanoid(8);
|
||||
this.process = new Process("ChunkedRequest", this, [relay]);
|
||||
this.process.icon = LayersThree01;
|
||||
this.relay = relay as AbstractRelay;
|
||||
this.filters = filters;
|
||||
|
||||
this.log = log || logger.extend(relay.url);
|
||||
this.events = new EventStore(relay.url);
|
||||
|
||||
// TODO: find a better place for this
|
||||
this.subs.push(deleteEventService.stream.subscribe((e) => this.handleDeleteEvent(e)));
|
||||
|
||||
processManager.registerProcess(this.process);
|
||||
}
|
||||
|
||||
async loadNextChunk() {
|
||||
if (this.loading) return;
|
||||
|
||||
// check if its possible to subscribe to this relay
|
||||
if (!relayPoolService.canSubscribe(this.relay)) {
|
||||
this.log("Cant subscribe to relay, aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
if (!this.relay.connected) {
|
||||
this.log("relay not connected, aborting");
|
||||
relayPoolService.requestConnect(this.relay);
|
||||
return;
|
||||
}
|
||||
|
||||
let filters: Filter[] = mergeFilter(this.filters, { limit: this.chunkSize });
|
||||
let oldestEvent = this.getLastEvent();
|
||||
if (oldestEvent) {
|
||||
filters = mergeFilter(filters, { until: oldestEvent.created_at - 1 });
|
||||
}
|
||||
|
||||
let gotEvents = 0;
|
||||
|
||||
this.process.active = true;
|
||||
await new Promise<number>((res) => {
|
||||
const sub = this.relay.subscribe(filters, {
|
||||
// @ts-expect-error
|
||||
id: this.id + "-" + this.lastChunkIdx++,
|
||||
onevent: (event) => {
|
||||
this.handleEvent(event);
|
||||
gotEvents++;
|
||||
},
|
||||
oneose: () => {
|
||||
this.loading = false;
|
||||
if (gotEvents === 0) {
|
||||
this.complete = true;
|
||||
this.log("Complete");
|
||||
} else {
|
||||
this.log(`Got ${gotEvents} events`);
|
||||
}
|
||||
|
||||
this.onChunkFinish.next(gotEvents);
|
||||
sub.close();
|
||||
this.process.active = false;
|
||||
res(gotEvents);
|
||||
},
|
||||
onclose: (reason) => {
|
||||
relayPoolService.handleRelayNotice(this.relay, reason);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
if (!matchFilters(this.filters, event)) return;
|
||||
return this.events.addEvent(event);
|
||||
}
|
||||
|
||||
private handleDeleteEvent(deleteEvent: NostrEvent) {
|
||||
const cord = deleteEvent.tags.find(isATag)?.[1];
|
||||
const eventId = deleteEvent.tags.find(isETag)?.[1];
|
||||
|
||||
if (cord) this.events.deleteEvent(cord);
|
||||
if (eventId) this.events.deleteEvent(eventId);
|
||||
}
|
||||
|
||||
getFirstEvent(nth = 0, eventFilter?: EventFilter) {
|
||||
return this.events.getFirstEvent(nth, eventFilter);
|
||||
}
|
||||
getLastEvent(nth = 0, eventFilter?: EventFilter) {
|
||||
return this.events.getLastEvent(nth, eventFilter);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const sub of this.subs) sub.unsubscribe();
|
||||
this.subs = [];
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
64
src/classes/controlled-observable.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import Observable from "zen-observable";
|
||||
|
||||
export default class ControlledObservable<T> implements Observable<T> {
|
||||
private observable: Observable<T>;
|
||||
private subscriptions = new Set<ZenObservable.SubscriptionObserver<T>>();
|
||||
private _complete = false;
|
||||
get closed() {
|
||||
return this._complete;
|
||||
}
|
||||
get used() {
|
||||
return this.subscriptions.size > 0;
|
||||
}
|
||||
|
||||
constructor(subscriber?: ZenObservable.Subscriber<T>) {
|
||||
this.observable = new Observable((observer) => {
|
||||
this.subscriptions.add(observer);
|
||||
const cleanup = subscriber && subscriber(observer);
|
||||
return () => {
|
||||
this.subscriptions.delete(observer);
|
||||
if (typeof cleanup === "function") cleanup();
|
||||
else if (cleanup?.unsubscribe) cleanup.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
this.subscribe = this.observable.subscribe.bind(this.observable);
|
||||
this.map = this.observable.map.bind(this.observable);
|
||||
this.flatMap = this.observable.flatMap.bind(this.observable);
|
||||
this.forEach = this.observable.forEach.bind(this.observable);
|
||||
this.reduce = this.observable.reduce.bind(this.observable);
|
||||
this.filter = this.observable.filter.bind(this.observable);
|
||||
this.concat = this.observable.concat.bind(this.observable);
|
||||
}
|
||||
|
||||
next(v: T) {
|
||||
if (this._complete) return;
|
||||
for (const observer of this.subscriptions) {
|
||||
observer.next(v);
|
||||
}
|
||||
}
|
||||
error(err: any) {
|
||||
if (this._complete) return;
|
||||
for (const observer of this.subscriptions) {
|
||||
observer.error(err);
|
||||
}
|
||||
}
|
||||
complete() {
|
||||
if (this._complete) return;
|
||||
this._complete = true;
|
||||
for (const observer of this.subscriptions) {
|
||||
observer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.observable]() {
|
||||
return this.observable;
|
||||
}
|
||||
subscribe: Observable<T>["subscribe"];
|
||||
map: Observable<T>["map"];
|
||||
flatMap: Observable<T>["flatMap"];
|
||||
forEach: Observable<T>["forEach"];
|
||||
reduce: Observable<T>["reduce"];
|
||||
filter: Observable<T>["filter"];
|
||||
concat: Observable<T>["concat"];
|
||||
}
|
21
src/classes/deferred.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type Deferred<T> = Promise<T> & {
|
||||
resolve: (value?: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
|
||||
export default function createDefer<T>() {
|
||||
let _resolve: (value?: T | PromiseLike<T>) => void;
|
||||
let _reject: (reason?: any) => void;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
// @ts-ignore
|
||||
_resolve = resolve;
|
||||
_reject = reject;
|
||||
}) as Deferred<T>;
|
||||
|
||||
// @ts-ignore
|
||||
promise.resolve = _resolve;
|
||||
// @ts-ignore
|
||||
promise.reject = _reject;
|
||||
|
||||
return promise;
|
||||
}
|
116
src/classes/event-store.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { getEventUID, sortByDate } from "../helpers/nostr/event";
|
||||
import ControlledObservable from "./controlled-observable";
|
||||
import SuperMap from "./super-map";
|
||||
import deleteEventService from "../services/delete-events";
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
/** a class used to store and sort events */
|
||||
export default class EventStore {
|
||||
id = nanoid(8);
|
||||
name?: string;
|
||||
events = new Map<string, NostrEvent>();
|
||||
|
||||
customSort?: typeof sortByDate;
|
||||
|
||||
private deleteSub: ZenObservable.Subscription;
|
||||
|
||||
constructor(name?: string, customSort?: typeof sortByDate) {
|
||||
this.name = name;
|
||||
this.customSort = customSort;
|
||||
|
||||
this.deleteSub = deleteEventService.stream.subscribe((event) => {
|
||||
const uid = getEventUID(event);
|
||||
this.deleteEvent(uid);
|
||||
if (uid !== event.id) this.deleteEvent(event.id);
|
||||
});
|
||||
}
|
||||
|
||||
getSortedEvents() {
|
||||
return Array.from(this.events.values()).sort(this.customSort || sortByDate);
|
||||
}
|
||||
|
||||
onEvent = new ControlledObservable<NostrEvent>();
|
||||
onDelete = new ControlledObservable<string>();
|
||||
onClear = new ControlledObservable();
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
const uid = getEventUID(event);
|
||||
const existing = this.events.get(uid);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
this.events.set(uid, event);
|
||||
this.onEvent.next(event);
|
||||
}
|
||||
}
|
||||
|
||||
addEvent(event: NostrEvent) {
|
||||
this.handleEvent(event);
|
||||
}
|
||||
getEvent(id: string) {
|
||||
return this.events.get(id);
|
||||
}
|
||||
deleteEvent(id: string) {
|
||||
if (this.events.has(id)) {
|
||||
this.events.delete(id);
|
||||
this.onDelete.next(id);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events.clear();
|
||||
this.onClear.next(undefined);
|
||||
}
|
||||
|
||||
private storeSubs = new SuperMap<EventStore, ZenObservable.Subscription[]>(() => []);
|
||||
connect(other: EventStore, fullSync = true) {
|
||||
const subs = this.storeSubs.get(other);
|
||||
subs.push(
|
||||
other.onEvent.subscribe((e) => {
|
||||
if (fullSync || this.events.has(getEventUID(e))) this.addEvent(e);
|
||||
}),
|
||||
);
|
||||
subs.push(other.onDelete.subscribe(this.deleteEvent.bind(this)));
|
||||
}
|
||||
disconnect(other: EventStore) {
|
||||
const subs = this.storeSubs.get(other);
|
||||
for (const sub of subs) sub.unsubscribe();
|
||||
this.storeSubs.delete(other);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.clear();
|
||||
for (const [_, subs] of this.storeSubs) {
|
||||
for (const sub of subs) sub.unsubscribe();
|
||||
}
|
||||
this.storeSubs.clear();
|
||||
this.deleteSub.unsubscribe();
|
||||
}
|
||||
|
||||
getFirstEvent(nth = 0, filter?: EventFilter) {
|
||||
const events = this.getSortedEvents();
|
||||
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const event = events.shift();
|
||||
if (!event) return;
|
||||
if (filter && !filter(event, this)) continue;
|
||||
if (i === nth) return event;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
getLastEvent(nth = 0, filter?: EventFilter) {
|
||||
const events = this.getSortedEvents();
|
||||
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const event = events.pop();
|
||||
if (!event) return;
|
||||
if (filter && !filter(event, this)) continue;
|
||||
if (i === nth) return event;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
96
src/classes/local-settings/entry.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { PersistentSubject } from "../subject";
|
||||
|
||||
export class NullableLocalStorageEntry<T = string> extends PersistentSubject<T | null> {
|
||||
key: string;
|
||||
decode?: (raw: string | null) => T | null;
|
||||
encode?: (value: T) => string | null;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
initValue: T | null = null,
|
||||
decode?: (raw: string | null) => T | null,
|
||||
encode?: (value: T) => string | null,
|
||||
) {
|
||||
let value = initValue;
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (decode) value = decode(raw);
|
||||
else value = raw as T | null;
|
||||
}
|
||||
|
||||
super(value);
|
||||
this.key = key;
|
||||
this.decode = decode;
|
||||
this.encode = encode;
|
||||
}
|
||||
|
||||
next(value: T | null) {
|
||||
if (value === null) {
|
||||
localStorage.removeItem(this.key);
|
||||
|
||||
super.next(value);
|
||||
} else {
|
||||
const encoded = this.encode ? this.encode(value) : String(value);
|
||||
if (encoded !== null) localStorage.setItem(this.key, encoded);
|
||||
else localStorage.removeItem(this.key);
|
||||
|
||||
super.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.next(null);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalStorageEntry<T = string> extends PersistentSubject<T> {
|
||||
key: string;
|
||||
fallback: T;
|
||||
decode?: (raw: string) => T;
|
||||
encode?: (value: T) => string | null;
|
||||
|
||||
setDefault = false;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
fallback: T,
|
||||
decode?: (raw: string) => T,
|
||||
encode?: (value: T) => string | null,
|
||||
setDefault = false,
|
||||
) {
|
||||
let value = fallback;
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (decode && raw) value = decode(raw);
|
||||
else if (raw) value = raw as T;
|
||||
} else if (setDefault) {
|
||||
const encoded = encode ? encode(fallback) : String(fallback);
|
||||
if (!encoded) throw new Error("encode can not return null when setDefault is set");
|
||||
localStorage.setItem(key, encoded);
|
||||
}
|
||||
|
||||
super(value);
|
||||
|
||||
this.key = key;
|
||||
this.decode = decode;
|
||||
this.encode = encode;
|
||||
this.fallback = fallback;
|
||||
this.setDefault = setDefault;
|
||||
}
|
||||
|
||||
next(value: T) {
|
||||
const encoded = this.encode ? this.encode(value) : String(value);
|
||||
if (encoded !== null) localStorage.setItem(this.key, encoded);
|
||||
else if (this.setDefault && encoded) localStorage.setItem(this.key, encoded);
|
||||
else localStorage.removeItem(this.key);
|
||||
|
||||
super.next(value);
|
||||
}
|
||||
|
||||
clear() {
|
||||
localStorage.removeItem(this.key);
|
||||
super.next(this.fallback);
|
||||
}
|
||||
}
|
34
src/classes/local-settings/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { LocalStorageEntry, NullableLocalStorageEntry } from "./entry";
|
||||
|
||||
export class NumberLocalStorageEntry extends LocalStorageEntry<number> {
|
||||
constructor(key: string, fallback: number) {
|
||||
super(
|
||||
key,
|
||||
fallback,
|
||||
(raw) => parseInt(raw),
|
||||
(value) => String(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class NullableNumberLocalStorageEntry extends NullableLocalStorageEntry<number> {
|
||||
constructor(key: string, fallback: number) {
|
||||
super(
|
||||
key,
|
||||
fallback,
|
||||
(raw) => (raw !== null ? parseInt(raw) : raw),
|
||||
(value) => String(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanLocalStorageEntry extends LocalStorageEntry<boolean> {
|
||||
constructor(key: string, fallback: boolean) {
|
||||
super(
|
||||
key,
|
||||
fallback,
|
||||
(raw) => raw === "true",
|
||||
(value) => String(value),
|
||||
);
|
||||
}
|
||||
}
|
84
src/classes/memory-relay.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { SimpleRelay, Subscription, SubscriptionOptions } from "nostr-idb";
|
||||
import { Filter, NostrEvent, matchFilters } from "nostr-tools";
|
||||
|
||||
import EventStore from "./event-store";
|
||||
import { logger } from "../helpers/debug";
|
||||
|
||||
export default class MemoryRelay implements SimpleRelay {
|
||||
log = logger.extend("MemoryRelay");
|
||||
connected = true;
|
||||
url = ":memory:";
|
||||
|
||||
events = new EventStore();
|
||||
subscriptions = new Map<string, Subscription>();
|
||||
|
||||
constructor() {
|
||||
this.events.onEvent.subscribe((event) => {
|
||||
for (const [id, sub] of this.subscriptions) {
|
||||
if (sub.onevent && matchFilters(sub.filters, event)) sub.onevent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async connect() {}
|
||||
close(): void {}
|
||||
|
||||
async publish(event: NostrEvent) {
|
||||
this.events.addEvent(event);
|
||||
return "accepted";
|
||||
}
|
||||
|
||||
private async executeSubscription(sub: Subscription) {
|
||||
const limit = sub.filters.reduce((v, f) => (f.limit ? Math.min(v, f.limit) : v), Infinity);
|
||||
|
||||
let count = 0;
|
||||
if (sub.onevent) {
|
||||
const events = this.events.getSortedEvents();
|
||||
for (const event of events) {
|
||||
if (matchFilters(sub.filters, event)) {
|
||||
sub.onevent(event);
|
||||
count++;
|
||||
}
|
||||
if (count === limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
this.log(`Ran ${sub.id} and got ${count} events`, sub.filters);
|
||||
|
||||
if (sub.oneose) sub.oneose();
|
||||
}
|
||||
|
||||
subscribe(filters: Filter[], options: SubscriptionOptions) {
|
||||
const sub: Subscription = {
|
||||
id: nanoid(8),
|
||||
filters,
|
||||
...options,
|
||||
fire: () => {
|
||||
this.executeSubscription(sub);
|
||||
},
|
||||
close: () => {
|
||||
this.subscriptions.delete(sub.id);
|
||||
},
|
||||
};
|
||||
|
||||
this.subscriptions.set(sub.id, sub);
|
||||
setTimeout(() => {
|
||||
this.executeSubscription(sub);
|
||||
}, 0);
|
||||
return sub;
|
||||
}
|
||||
|
||||
async count(
|
||||
filters: Filter[],
|
||||
params?: {
|
||||
id?: string | null;
|
||||
},
|
||||
) {
|
||||
let count = 0;
|
||||
for (const [id, event] of this.events.events) {
|
||||
if (matchFilters(filters, event)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
190
src/classes/multi-subscription.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { Filter } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { isFilterEqual } from "../helpers/nostr/filter";
|
||||
import ControlledObservable from "./controlled-observable";
|
||||
import { offlineMode } from "../services/offline-mode";
|
||||
import PersistentSubscription from "./persistent-subscription";
|
||||
import Process from "./process";
|
||||
import Dataflow01 from "../components/icons/dataflow-01";
|
||||
import processManager from "../services/process-manager";
|
||||
import { localRelay } from "../services/local-relay";
|
||||
|
||||
export default class MultiSubscription {
|
||||
static OPEN = "open";
|
||||
static CLOSED = "closed";
|
||||
|
||||
id: string;
|
||||
name: string;
|
||||
process: Process;
|
||||
filters: Filter[] = [];
|
||||
|
||||
relays = new Set<AbstractRelay>();
|
||||
subscriptions = new Map<AbstractRelay, PersistentSubscription>();
|
||||
|
||||
useCache = true;
|
||||
cacheSubscription: PersistentSubscription | null = null;
|
||||
onCacheEvent = new ControlledObservable<NostrEvent>();
|
||||
|
||||
state = MultiSubscription.CLOSED;
|
||||
onEvent = new ControlledObservable<NostrEvent>();
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
constructor(name: string) {
|
||||
this.id = nanoid(8);
|
||||
this.name = name;
|
||||
this.process = new Process("MultiSubscription", this);
|
||||
this.process.name = this.name;
|
||||
this.process.icon = Dataflow01;
|
||||
|
||||
processManager.registerProcess(this.process);
|
||||
}
|
||||
private handleEvent(event: NostrEvent, fromCache = false) {
|
||||
if (this.seenEvents.has(event.id)) return;
|
||||
|
||||
if (fromCache) this.onCacheEvent.next(event);
|
||||
else this.onEvent.next(event);
|
||||
|
||||
this.seenEvents.add(event.id);
|
||||
}
|
||||
|
||||
setFilters(filters: Filter[]) {
|
||||
if (isFilterEqual(this.filters, filters)) return;
|
||||
this.filters = filters;
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
setRelays(relays: Iterable<string | URL | AbstractRelay>) {
|
||||
const newRelays = relayPoolService.getRelays(relays);
|
||||
|
||||
// remove relays
|
||||
for (const relay of this.relays) {
|
||||
if (!newRelays.includes(relay)) {
|
||||
this.relays.delete(relay);
|
||||
const sub = this.subscriptions.get(relay);
|
||||
if (sub) {
|
||||
sub.destroy();
|
||||
this.subscriptions.delete(relay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add relays
|
||||
for (const relay of newRelays) {
|
||||
this.relays.add(relay);
|
||||
}
|
||||
|
||||
this.updateSubscriptions();
|
||||
}
|
||||
|
||||
private updateSubscriptions() {
|
||||
// close all subscriptions if not open
|
||||
if (this.state !== MultiSubscription.OPEN) {
|
||||
for (const [relay, subscription] of this.subscriptions) subscription.close();
|
||||
this.cacheSubscription?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// else open and update subscriptions
|
||||
for (const relay of this.relays) {
|
||||
let subscription = this.subscriptions.get(relay);
|
||||
if (!subscription || !isFilterEqual(subscription.filters, this.filters) || subscription.closed) {
|
||||
if (!subscription) {
|
||||
subscription = new PersistentSubscription(relay, {
|
||||
onevent: (event) => this.handleEvent(event),
|
||||
});
|
||||
|
||||
this.process.addChild(subscription.process);
|
||||
this.subscriptions.set(relay, subscription);
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
subscription.filters = this.filters;
|
||||
subscription.update().catch((err) => {
|
||||
// eat error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.useCache) {
|
||||
// create cache sub if it does not exist
|
||||
if (!this.cacheSubscription && localRelay) {
|
||||
this.cacheSubscription = new PersistentSubscription(localRelay as AbstractRelay, {
|
||||
onevent: (event) => this.handleEvent(event, true),
|
||||
});
|
||||
this.process.addChild(this.cacheSubscription.process);
|
||||
}
|
||||
|
||||
// update cache sub filters if they changed
|
||||
if (
|
||||
this.cacheSubscription &&
|
||||
(!isFilterEqual(this.cacheSubscription.filters, this.filters) || this.cacheSubscription.closed)
|
||||
) {
|
||||
this.cacheSubscription.filters = this.filters;
|
||||
this.cacheSubscription.update().catch((err) => {
|
||||
// eat error
|
||||
});
|
||||
}
|
||||
} else if (this.cacheSubscription?.closed === false) {
|
||||
this.cacheSubscription.close();
|
||||
}
|
||||
}
|
||||
|
||||
publish(event: NostrEvent) {
|
||||
return Promise.allSettled(
|
||||
Array.from(this.relays).map(async (r) => {
|
||||
if (!r.connected) await relayPoolService.requestConnect(r);
|
||||
return await r.publish(event);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.state === MultiSubscription.OPEN) return this;
|
||||
|
||||
this.state = MultiSubscription.OPEN;
|
||||
this.updateSubscriptions();
|
||||
this.process.active = true;
|
||||
|
||||
return this;
|
||||
}
|
||||
waitForAllConnection(): Promise<void> {
|
||||
if (offlineMode.value) return Promise.resolve();
|
||||
return Promise.allSettled(
|
||||
Array.from(this.relays)
|
||||
.filter((r) => !r.connected)
|
||||
.map((r) => r.connect()),
|
||||
).then((v) => void 0);
|
||||
}
|
||||
close() {
|
||||
if (this.state !== MultiSubscription.OPEN) return this;
|
||||
|
||||
// forget all seen events
|
||||
this.forgetEvents();
|
||||
// unsubscribe from relay messages
|
||||
this.state = MultiSubscription.CLOSED;
|
||||
this.process.active = false;
|
||||
|
||||
// close all
|
||||
this.updateSubscriptions();
|
||||
|
||||
return this;
|
||||
}
|
||||
forgetEvents() {
|
||||
// forget all seen events
|
||||
this.seenEvents.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const [relay, sub] of this.subscriptions) {
|
||||
sub.destroy();
|
||||
}
|
||||
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
63
src/classes/nostr-publish-action.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import createDefer from "./deferred";
|
||||
import { PersistentSubject } from "./subject";
|
||||
import ControlledObservable from "./controlled-observable";
|
||||
|
||||
export type PublishResult = { relay: AbstractRelay; success: boolean; message: string };
|
||||
|
||||
export default class PublishAction {
|
||||
id = nanoid(8);
|
||||
label: string;
|
||||
relays: string[];
|
||||
event: NostrEvent;
|
||||
|
||||
results = new PersistentSubject<PublishResult[]>([]);
|
||||
completePromise = createDefer();
|
||||
|
||||
/** @deprecated */
|
||||
onResult = new ControlledObservable<PublishResult>();
|
||||
|
||||
private remaining = new Set<AbstractRelay>();
|
||||
|
||||
constructor(label: string, relays: Iterable<string>, event: NostrEvent, timeout: number = 10_000) {
|
||||
this.label = label;
|
||||
this.relays = Array.from(relays);
|
||||
this.event = event;
|
||||
|
||||
for (const url of relays) {
|
||||
const relay = relayPoolService.requestRelay(url);
|
||||
this.remaining.add(relay);
|
||||
|
||||
relay
|
||||
.publish(event)
|
||||
.then((result) => this.handleResult(event.id, true, result, relay))
|
||||
.catch((err) => {
|
||||
if (err instanceof Error) this.handleResult(event.id, false, err.message, relay);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(this.handleTimeout.bind(this), timeout);
|
||||
}
|
||||
|
||||
private handleResult(id: string, success: boolean, message: string, relay: AbstractRelay) {
|
||||
const result: PublishResult = { relay, success, message };
|
||||
this.results.next([...this.results.value.filter((r) => r.relay !== relay), result]);
|
||||
this.onResult.next(result);
|
||||
|
||||
this.remaining.delete(relay);
|
||||
|
||||
if (this.remaining.size === 0) {
|
||||
this.completePromise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
for (const relay of this.remaining) {
|
||||
this.handleResult(this.event.id, false, "Timeout", relay);
|
||||
}
|
||||
}
|
||||
}
|
64
src/classes/nostr-subscription.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { Filter, NostrEvent, Relay } from "nostr-tools";
|
||||
import { Subscription } from "nostr-tools/abstract-relay";
|
||||
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import ControlledObservable from "./controlled-observable";
|
||||
|
||||
/** @deprecated use relay.subscribe instead */
|
||||
export default class NostrSubscription {
|
||||
static INIT = "initial";
|
||||
static OPEN = "open";
|
||||
static CLOSED = "closed";
|
||||
|
||||
id: string;
|
||||
name?: string;
|
||||
filters?: Filter[];
|
||||
relay: Relay;
|
||||
state = NostrSubscription.INIT;
|
||||
|
||||
subscription: Subscription | null = null;
|
||||
|
||||
onEvent = new ControlledObservable<NostrEvent>();
|
||||
onEOSE = new ControlledObservable<number>();
|
||||
|
||||
constructor(relayUrl: string | URL, filters?: Filter[], name?: string) {
|
||||
this.id = nanoid(8);
|
||||
this.filters = filters;
|
||||
this.name = name;
|
||||
|
||||
this.relay = relayPoolService.requestRelay(relayUrl);
|
||||
}
|
||||
|
||||
setFilters(filters: Filter[]) {
|
||||
this.filters = filters;
|
||||
if (this.state === NostrSubscription.OPEN && this.subscription) {
|
||||
this.subscription.filters = this.filters;
|
||||
this.subscription.fire();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!this.filters) throw new Error("cant open without a query");
|
||||
if (this.state === NostrSubscription.OPEN) return this;
|
||||
|
||||
this.state = NostrSubscription.OPEN;
|
||||
this.subscription = this.relay.subscribe(this.filters, {
|
||||
onevent: (event) => this.onEvent.next(event),
|
||||
oneose: () => this.onEOSE.next(Math.random()),
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
close() {
|
||||
if (this.state !== NostrSubscription.OPEN) return this;
|
||||
|
||||
// set state
|
||||
this.state = NostrSubscription.CLOSED;
|
||||
// send close message
|
||||
this.subscription?.close();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
152
src/classes/notifications.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { NostrEvent, kinds, nip18, nip25 } from "nostr-tools";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import EventStore from "./event-store";
|
||||
import { PersistentSubject } from "./subject";
|
||||
import { getThreadReferences, isPTagMentionedInContent, isReply, isRepost } from "../helpers/nostr/event";
|
||||
import { getParsedZap } from "../helpers/nostr/zaps";
|
||||
import singleEventService from "../services/single-event";
|
||||
import RelaySet from "./relay-set";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import { getPubkeysMentionedInContent } from "../helpers/nostr/post";
|
||||
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
|
||||
import { STREAM_CHAT_MESSAGE_KIND } from "../helpers/nostr/stream";
|
||||
import replaceableEventsService from "../services/replaceable-events";
|
||||
import { MUTE_LIST_KIND, getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
|
||||
export const typeSymbol = Symbol("notificationType");
|
||||
|
||||
export enum NotificationType {
|
||||
Reply = "reply",
|
||||
Repost = "repost",
|
||||
Zap = "zap",
|
||||
Reaction = "reaction",
|
||||
Mention = "mention",
|
||||
}
|
||||
export type CategorizedEvent = NostrEvent & { [typeSymbol]?: NotificationType };
|
||||
|
||||
export default class AccountNotifications {
|
||||
store: EventStore;
|
||||
pubkey: string;
|
||||
private subs: ZenObservable.Subscription[] = [];
|
||||
|
||||
timeline = new PersistentSubject<CategorizedEvent[]>([]);
|
||||
|
||||
constructor(pubkey: string, store: EventStore) {
|
||||
this.store = store;
|
||||
this.pubkey = pubkey;
|
||||
|
||||
this.subs.push(store.onEvent.subscribe(this.handleEvent.bind(this)));
|
||||
|
||||
for (const [_, event] of store.events) this.handleEvent(event);
|
||||
}
|
||||
|
||||
private categorizeEvent(event: NostrEvent): CategorizedEvent {
|
||||
const e = event as CategorizedEvent;
|
||||
if (event.kind === kinds.Zap) {
|
||||
e[typeSymbol] = NotificationType.Zap;
|
||||
} else if (event.kind === kinds.Reaction) {
|
||||
e[typeSymbol] = NotificationType.Reaction;
|
||||
} else if (isRepost(event)) {
|
||||
e[typeSymbol] = NotificationType.Repost;
|
||||
} else if (
|
||||
event.kind === kinds.ShortTextNote ||
|
||||
event.kind === TORRENT_COMMENT_KIND ||
|
||||
event.kind === STREAM_CHAT_MESSAGE_KIND ||
|
||||
event.kind === kinds.LongFormArticle
|
||||
) {
|
||||
// is the "p" tag directly mentioned in the content
|
||||
const isMentioned = isPTagMentionedInContent(event, this.pubkey);
|
||||
// is the pubkey mentioned in any way in the content
|
||||
const isQuoted = getPubkeysMentionedInContent(event.content).includes(this.pubkey);
|
||||
|
||||
if (isMentioned || isQuoted) e[typeSymbol] = NotificationType.Mention;
|
||||
else if (isReply(event)) e[typeSymbol] = NotificationType.Reply;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
const e = this.categorizeEvent(event);
|
||||
|
||||
const getAndSubscribe = (eventId: string, relays?: string[]) => {
|
||||
const subject = singleEventService.requestEvent(
|
||||
eventId,
|
||||
RelaySet.from(clientRelaysService.readRelays.value, relays),
|
||||
);
|
||||
|
||||
subject.once(this.throttleUpdateTimeline);
|
||||
return subject.value;
|
||||
};
|
||||
|
||||
switch (e[typeSymbol]) {
|
||||
case NotificationType.Reply:
|
||||
const refs = getThreadReferences(e);
|
||||
if (refs.reply?.e?.id) getAndSubscribe(refs.reply.e.id, refs.reply.e.relays);
|
||||
break;
|
||||
case NotificationType.Reaction: {
|
||||
const pointer = nip25.getReactedEventPointer(e);
|
||||
if (pointer?.id) getAndSubscribe(pointer.id, pointer.relays);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throttleUpdateTimeline = _throttle(this.updateTimeline.bind(this), 200);
|
||||
updateTimeline() {
|
||||
const muteList = replaceableEventsService.getEvent(MUTE_LIST_KIND, this.pubkey).value;
|
||||
const mutedPubkeys = muteList ? getPubkeysFromList(muteList).map((p) => p.pubkey) : [];
|
||||
|
||||
const sorted = this.store.getSortedEvents();
|
||||
|
||||
const timeline: CategorizedEvent[] = [];
|
||||
for (const event of sorted) {
|
||||
if (!Object.hasOwn(event, typeSymbol)) continue;
|
||||
if (mutedPubkeys.includes(event.pubkey)) continue;
|
||||
if (event.pubkey === this.pubkey) continue;
|
||||
const e = event as CategorizedEvent;
|
||||
|
||||
switch (e[typeSymbol]) {
|
||||
case NotificationType.Reply:
|
||||
const refs = getThreadReferences(e);
|
||||
if (!refs.reply?.e?.id) break;
|
||||
if (refs.reply?.e?.author && refs.reply?.e?.author !== this.pubkey) break;
|
||||
const parent = singleEventService.getSubject(refs.reply.e.id).value;
|
||||
if (!parent || parent.pubkey !== this.pubkey) break;
|
||||
timeline.push(e);
|
||||
break;
|
||||
case NotificationType.Mention:
|
||||
timeline.push(e);
|
||||
break;
|
||||
case NotificationType.Repost: {
|
||||
const pointer = nip18.getRepostedEventPointer(e);
|
||||
if (pointer?.author !== this.pubkey) break;
|
||||
timeline.push(e);
|
||||
break;
|
||||
}
|
||||
case NotificationType.Reaction: {
|
||||
const pointer = nip25.getReactedEventPointer(e);
|
||||
if (!pointer) break;
|
||||
if (pointer.author !== this.pubkey) break;
|
||||
if (pointer.kind === kinds.EncryptedDirectMessage) break;
|
||||
const parent = singleEventService.getSubject(pointer.id).value;
|
||||
if (parent && parent.kind === kinds.EncryptedDirectMessage) break;
|
||||
timeline.push(e);
|
||||
break;
|
||||
}
|
||||
case NotificationType.Zap:
|
||||
const parsed = getParsedZap(e, true, true);
|
||||
if (parsed instanceof Error) break;
|
||||
if (!parsed.payment.amount) break;
|
||||
timeline.push(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.timeline.next(timeline);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const sub of this.subs) sub.unsubscribe();
|
||||
this.subs = [];
|
||||
}
|
||||
}
|
91
src/classes/persistent-subscription.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { Filter, Relay } from "nostr-tools";
|
||||
import { AbstractRelay, Subscription, SubscriptionParams } from "nostr-tools/abstract-relay";
|
||||
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import Process from "./process";
|
||||
import FilterFunnel01 from "../components/icons/filter-funnel-01";
|
||||
import processManager from "../services/process-manager";
|
||||
import { isFilterEqual } from "../helpers/nostr/filter";
|
||||
|
||||
export default class PersistentSubscription {
|
||||
id: string;
|
||||
process: Process;
|
||||
relay: Relay;
|
||||
filters: Filter[];
|
||||
closed = true;
|
||||
params: Partial<SubscriptionParams>;
|
||||
|
||||
subscription: Subscription | null = null;
|
||||
get eosed() {
|
||||
return !!this.subscription?.eosed;
|
||||
}
|
||||
|
||||
constructor(relay: AbstractRelay, params?: Partial<SubscriptionParams>) {
|
||||
this.id = nanoid(8);
|
||||
this.process = new Process("PersistentSubscription", this, [relay]);
|
||||
this.process.icon = FilterFunnel01;
|
||||
this.filters = [];
|
||||
this.params = {
|
||||
//@ts-expect-error
|
||||
id: this.id,
|
||||
...params,
|
||||
};
|
||||
|
||||
this.relay = relay;
|
||||
|
||||
processManager.registerProcess(this.process);
|
||||
}
|
||||
|
||||
/** attempts to update the subscription */
|
||||
async update() {
|
||||
if (!this.filters || this.filters.length === 0) throw new Error("Missing filters");
|
||||
|
||||
if (!(await relayPoolService.waitForOpen(this.relay))) throw new Error("Failed to connect to relay");
|
||||
|
||||
// check if its possible to subscribe to this relay
|
||||
if (!relayPoolService.canSubscribe(this.relay)) throw new Error("Cant subscribe to relay");
|
||||
|
||||
this.closed = false;
|
||||
this.process.active = true;
|
||||
|
||||
// recreate the subscription if its closed since nostr-tools cant reopen a sub
|
||||
if (!this.subscription || this.subscription.closed) {
|
||||
this.subscription = this.relay.subscribe(this.filters, {
|
||||
...this.params,
|
||||
oneose: () => {
|
||||
this.params.oneose?.();
|
||||
},
|
||||
onclose: (reason) => {
|
||||
if (!this.closed) {
|
||||
relayPoolService.handleRelayNotice(this.relay, reason);
|
||||
|
||||
this.closed = true;
|
||||
this.process.active = false;
|
||||
}
|
||||
this.params.onclose?.(reason);
|
||||
},
|
||||
});
|
||||
} else if (isFilterEqual(this.subscription.filters, this.filters) === false) {
|
||||
this.subscription.filters = this.filters;
|
||||
// NOTE: reset the eosed flag since nostr-tools dose not
|
||||
this.subscription.eosed = false;
|
||||
this.subscription.fire();
|
||||
} else throw new Error("Subscription filters have not changed");
|
||||
}
|
||||
close() {
|
||||
if (this.closed) return this;
|
||||
|
||||
this.closed = true;
|
||||
if (this.subscription?.closed === false) this.subscription.close();
|
||||
this.process.active = false;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
53
src/classes/process.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { ComponentWithAs, IconProps } from "@chakra-ui/react";
|
||||
import { SimpleRelay } from "nostr-idb";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
|
||||
let lastId = 0;
|
||||
export default class Process {
|
||||
id = ++lastId;
|
||||
type: string;
|
||||
name?: string;
|
||||
icon?: ComponentWithAs<"svg", IconProps>;
|
||||
source: any;
|
||||
|
||||
// if this process is running
|
||||
active: boolean = false;
|
||||
|
||||
// the relays this process is claiming
|
||||
relays = new Set<AbstractRelay | SimpleRelay>();
|
||||
|
||||
// the parent process
|
||||
parent?: Process;
|
||||
// any children this process has created
|
||||
children = new Set<Process>();
|
||||
|
||||
constructor(type: string, source: any, relays?: Iterable<AbstractRelay | SimpleRelay>) {
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
|
||||
this.relays = new Set(relays);
|
||||
}
|
||||
|
||||
static forkOrCreate(name: string, source: any, relays: Iterable<AbstractRelay | SimpleRelay>, parent?: Process) {
|
||||
return parent?.fork(name, source, relays) || new Process("BatchKindLoader", this, relays);
|
||||
}
|
||||
|
||||
addChild(child: Process) {
|
||||
if (child === this) throw new Error("Process cant be a child of itself");
|
||||
this.children.add(child);
|
||||
child.parent = this;
|
||||
}
|
||||
|
||||
fork(name: string, source: any, relays?: Iterable<AbstractRelay | SimpleRelay>) {
|
||||
const child = new Process(name, source, relays);
|
||||
this.addChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
|
||||
this.parent.children.delete(this);
|
||||
this.parent = undefined;
|
||||
}
|
||||
}
|
165
src/classes/pubkey-graph.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import { logger } from "../helpers/debug";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
type EventMap = {
|
||||
computed: [];
|
||||
};
|
||||
|
||||
export class PubkeyGraph extends EventEmitter<EventMap> {
|
||||
/** the pubkey at the center of it all */
|
||||
root: string;
|
||||
log = logger.extend("PubkeyGraph");
|
||||
|
||||
/** a map of what pubkeys follow other pubkeys */
|
||||
connections = new Map<string, string[]>();
|
||||
distance = new Map<string, number>();
|
||||
|
||||
// number of connections a key has at each level
|
||||
connectionCount = new Map<string, number>();
|
||||
|
||||
constructor(root: string) {
|
||||
super();
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
const keys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
|
||||
for (const key of keys) this.changed.add(key);
|
||||
this.setPubkeyConnections(event.pubkey, keys);
|
||||
}
|
||||
|
||||
setPubkeyConnections(pubkey: string, friends: string[]) {
|
||||
this.connections.set(pubkey, friends);
|
||||
}
|
||||
|
||||
getByDistance() {
|
||||
const dist: Record<number, [string, number | undefined][]> = {};
|
||||
|
||||
for (const [key, d] of this.distance) {
|
||||
dist[d] = dist[d] || [];
|
||||
|
||||
dist[d].push([key, this.connectionCount.get(key)]);
|
||||
}
|
||||
|
||||
// sort keys
|
||||
for (const [d, keys] of Object.entries(dist)) {
|
||||
keys.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0));
|
||||
}
|
||||
|
||||
return dist;
|
||||
}
|
||||
|
||||
getPubkeyDistance(pubkey: string) {
|
||||
const distance = this.distance.get(pubkey);
|
||||
if (!distance) return;
|
||||
const count = this.connectionCount.get(pubkey);
|
||||
return { distance, count };
|
||||
}
|
||||
|
||||
sortByDistanceAndConnections(keys: string[]): string[];
|
||||
sortByDistanceAndConnections<T>(keys: T[], getKey: (d: T) => string): T[];
|
||||
sortByDistanceAndConnections<T>(keys: T[], getKey?: (d: T) => string): T[] {
|
||||
return Array.from(keys).sort((a, b) => {
|
||||
const aKey = typeof a === "string" ? a : getKey?.(a) || "";
|
||||
const bKey = typeof b === "string" ? b : getKey?.(b) || "";
|
||||
|
||||
const v = this.sortComparePubkeys(aKey, bKey);
|
||||
if (v === 0) {
|
||||
// tied break with original index
|
||||
const ai = keys.indexOf(a);
|
||||
const bi = keys.indexOf(b);
|
||||
if (ai < bi) return -1;
|
||||
else if (ai > bi) return 1;
|
||||
return 0;
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
sortComparePubkeys(a: string, b: string) {
|
||||
const aDist = this.distance.get(a);
|
||||
const bDist = this.distance.get(b);
|
||||
|
||||
if (!aDist && !bDist) return 0;
|
||||
else if (aDist && (!bDist || aDist < bDist)) return -1;
|
||||
else if (bDist && (!aDist || aDist > bDist)) return 1;
|
||||
|
||||
// a and b are on the same level. compare connections
|
||||
const aCount = this.connectionCount.get(a);
|
||||
const bCount = this.connectionCount.get(b);
|
||||
|
||||
if (aCount === bCount) return 0;
|
||||
else if (aCount && (!bCount || aCount < bCount)) return -1;
|
||||
else if (bCount && (!aCount || aCount > bCount)) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
throttleCompute = _throttle(this.compute.bind(this), 5_000, { leading: false });
|
||||
|
||||
changed = new Set<string>();
|
||||
compute() {
|
||||
this.distance.clear();
|
||||
|
||||
const next = new Set<string>();
|
||||
const refCount = new Map<string, number>();
|
||||
const walkLevel = (level = 0) => {
|
||||
if (next.size === 0) return;
|
||||
let keys = new Set(next);
|
||||
next.clear();
|
||||
|
||||
for (const key of keys) {
|
||||
this.distance.set(key, level);
|
||||
const count = refCount.get(key);
|
||||
if (count) this.connectionCount.set(key, count);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const connections = this.connections.get(key);
|
||||
if (connections) {
|
||||
for (const child of connections) {
|
||||
if (!this.distance.has(child)) {
|
||||
next.add(child);
|
||||
refCount.set(child, (refCount.get(child) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkLevel(level + 1);
|
||||
};
|
||||
|
||||
console.time("walk");
|
||||
next.add(this.root);
|
||||
walkLevel(0);
|
||||
console.timeEnd("walk");
|
||||
|
||||
this.emit("computed");
|
||||
}
|
||||
|
||||
getPaths(pubkey: string, maxLength = 2) {
|
||||
let paths: string[][] = [];
|
||||
|
||||
const walk = (p: string, maxLvl = 0, path: string[] = []) => {
|
||||
if (path.includes(p)) return;
|
||||
|
||||
const connections = this.connections.get(p);
|
||||
if (!connections) return;
|
||||
|
||||
for (const friend of connections) {
|
||||
if (friend === pubkey) {
|
||||
paths.push([...path, p, friend]);
|
||||
} else if (maxLvl > 0) {
|
||||
walk(friend, maxLvl - 1, [...path, p]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(this.root, maxLength);
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
237
src/classes/relay-pool.ts
Normal file
@ -0,0 +1,237 @@
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { logger } from "../helpers/debug";
|
||||
import { safeRelayUrl, validateRelayURL } from "../helpers/relay";
|
||||
import Subject, { PersistentSubject } from "./subject";
|
||||
import SuperMap from "./super-map";
|
||||
import verifyEventMethod from "../services/verify-event";
|
||||
import { offlineMode } from "../services/offline-mode";
|
||||
import processManager from "../services/process-manager";
|
||||
import signingService from "../services/signing";
|
||||
import accountService from "../services/account";
|
||||
|
||||
export type Notice = {
|
||||
message: string;
|
||||
date: number;
|
||||
relay: AbstractRelay;
|
||||
};
|
||||
|
||||
export type RelayAuthMode = "always" | "ask" | "never";
|
||||
|
||||
export default class RelayPool {
|
||||
relays = new Map<string, AbstractRelay>();
|
||||
onRelayCreated = new Subject<AbstractRelay>();
|
||||
onRelayChallenge = new Subject<[AbstractRelay, string]>();
|
||||
|
||||
notices = new SuperMap<AbstractRelay, PersistentSubject<Notice[]>>(() => new PersistentSubject<Notice[]>([]));
|
||||
|
||||
connectionErrors = new SuperMap<AbstractRelay, Error[]>(() => []);
|
||||
connecting = new SuperMap<AbstractRelay, PersistentSubject<boolean>>(() => new PersistentSubject(false));
|
||||
|
||||
challenges = new SuperMap<AbstractRelay, Subject<string>>(() => new Subject<string>());
|
||||
authForPublish = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
|
||||
authForSubscribe = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
|
||||
|
||||
authenticated = new SuperMap<AbstractRelay, Subject<boolean>>(() => new Subject());
|
||||
|
||||
log = logger.extend("RelayPool");
|
||||
|
||||
getRelay(relayOrUrl: string | URL | AbstractRelay) {
|
||||
if (typeof relayOrUrl === "string") {
|
||||
const safeURL = safeRelayUrl(relayOrUrl);
|
||||
if (safeURL) {
|
||||
return this.relays.get(safeURL) || this.requestRelay(safeURL);
|
||||
} else return;
|
||||
} else if (relayOrUrl instanceof URL) {
|
||||
return this.relays.get(relayOrUrl.toString()) || this.requestRelay(relayOrUrl.toString());
|
||||
}
|
||||
|
||||
return relayOrUrl;
|
||||
}
|
||||
|
||||
getRelays(urls?: Iterable<string | URL | AbstractRelay>) {
|
||||
if (urls) {
|
||||
const relays: AbstractRelay[] = [];
|
||||
for (const url of urls) {
|
||||
const relay = this.getRelay(url);
|
||||
if (relay) relays.push(relay);
|
||||
}
|
||||
return relays;
|
||||
}
|
||||
|
||||
return Array.from(this.relays.values());
|
||||
}
|
||||
|
||||
requestRelay(url: string | URL, connect = true) {
|
||||
url = validateRelayURL(url);
|
||||
|
||||
const key = url.toString();
|
||||
if (!this.relays.has(key)) {
|
||||
const r = new AbstractRelay(key, { verifyEvent: verifyEventMethod });
|
||||
r._onauth = (challenge) => {
|
||||
this.onRelayChallenge.next([r, challenge]);
|
||||
this.challenges.get(r).next(challenge);
|
||||
};
|
||||
r.onnotice = (notice) => this.handleRelayNotice(r, notice);
|
||||
|
||||
this.relays.set(key, r);
|
||||
this.onRelayCreated.next(r);
|
||||
}
|
||||
|
||||
const relay = this.relays.get(key) as AbstractRelay;
|
||||
if (connect && !relay.connected) this.requestConnect(relay);
|
||||
return relay;
|
||||
}
|
||||
|
||||
async waitForOpen(relayOrUrl: string | URL | AbstractRelay, quite = true) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return Promise.reject("Missing relay");
|
||||
|
||||
if (relay.connected) return true;
|
||||
|
||||
try {
|
||||
// if the relay is connecting, wait. otherwise request a connection
|
||||
// @ts-expect-error
|
||||
(await relay.connectionPromise) || this.requestConnect(relay, quite);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (quite) return false;
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async requestConnect(relayOrUrl: string | URL | AbstractRelay, quite = true) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return;
|
||||
|
||||
if (!relay.connected && !offlineMode.value) {
|
||||
this.connecting.get(relay).next(true);
|
||||
try {
|
||||
await relay.connect();
|
||||
this.connecting.get(relay).next(false);
|
||||
} catch (e) {
|
||||
e = e || new Error("Unknown error");
|
||||
if (e instanceof Error) {
|
||||
this.log(`Failed to connect to ${relay.url}`, e.message);
|
||||
this.connectionErrors.get(relay).push(e);
|
||||
}
|
||||
this.connecting.get(relay).next(false);
|
||||
if (!quite) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRelayAuthStorageKey(relayOrUrl: string | URL | AbstractRelay) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
return `${relay!.url}-auth-mode`;
|
||||
}
|
||||
getRelayAuthMode(relayOrUrl: string | URL | AbstractRelay): RelayAuthMode | undefined {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return;
|
||||
|
||||
const defaultMode = (localStorage.getItem(`default-auth-mode`) as RelayAuthMode) ?? undefined;
|
||||
const mode = (localStorage.getItem(this.getRelayAuthStorageKey(relay)) as RelayAuthMode) ?? undefined;
|
||||
|
||||
return mode || defaultMode;
|
||||
}
|
||||
setRelayAuthMode(relayOrUrl: string | URL | AbstractRelay, mode: RelayAuthMode) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return;
|
||||
|
||||
localStorage.setItem(this.getRelayAuthStorageKey(relay), mode);
|
||||
}
|
||||
|
||||
pendingAuth = new Map<AbstractRelay, Promise<string | undefined>>();
|
||||
async authenticate(
|
||||
relayOrUrl: string | URL | AbstractRelay,
|
||||
sign: Parameters<AbstractRelay["auth"]>[0],
|
||||
quite = true,
|
||||
) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return;
|
||||
|
||||
const pending = this.pendingAuth.get(relay);
|
||||
if (pending) return pending;
|
||||
|
||||
if (this.getRelayAuthMode(relay) === "never") throw new Error("Auth disabled for relay");
|
||||
|
||||
if (!relay.connected) throw new Error("Not connected");
|
||||
|
||||
const promise = new Promise<string | undefined>(async (res) => {
|
||||
if (!relay) return;
|
||||
|
||||
try {
|
||||
const message = await relay.auth(sign);
|
||||
this.authenticated.get(relay).next(true);
|
||||
res(message);
|
||||
} catch (e) {
|
||||
e = e || new Error("Unknown error");
|
||||
if (e instanceof Error) {
|
||||
this.log(`Failed to authenticate to ${relay.url}`, e.message);
|
||||
}
|
||||
this.authenticated.get(relay).next(false);
|
||||
if (!quite) throw e;
|
||||
}
|
||||
|
||||
this.pendingAuth.delete(relay);
|
||||
});
|
||||
|
||||
this.pendingAuth.set(relay, promise);
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
canSubscribe(relayOrUrl: string | URL | AbstractRelay) {
|
||||
let relay = this.getRelay(relayOrUrl);
|
||||
if (!relay) return false;
|
||||
|
||||
return this.authForSubscribe.get(relay).value !== false;
|
||||
}
|
||||
|
||||
handleRelayNotice(relay: AbstractRelay, message: string) {
|
||||
const subject = this.notices.get(relay);
|
||||
subject.next([...subject.value, { message, date: dayjs().unix(), relay }]);
|
||||
|
||||
if (message.includes("auth-required")) {
|
||||
const authForSubscribe = this.authForSubscribe.get(relay);
|
||||
if (!authForSubscribe.value) authForSubscribe.next(true);
|
||||
|
||||
const account = accountService.current.value;
|
||||
if (account) {
|
||||
const authMode = this.getRelayAuthMode(relay);
|
||||
|
||||
if (authMode === "always") {
|
||||
this.authenticate(relay, (draft) => {
|
||||
return signingService.requestSignature(draft, account);
|
||||
}).then(() => {
|
||||
this.log(`Automatically authenticated to ${relay.url}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectFromUnused() {
|
||||
for (const [url, relay] of this.relays) {
|
||||
if (!relay.connected) continue;
|
||||
|
||||
let disconnect = true;
|
||||
for (const process of processManager.processes) {
|
||||
if (process.active && process.relays.has(relay)) {
|
||||
disconnect = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (disconnect) {
|
||||
this.log(`No active processes using ${relay.url}, disconnecting`);
|
||||
relay.close();
|
||||
|
||||
// NOTE: fix nostr-tools not resetting the connection promise
|
||||
// @ts-expect-error
|
||||
relay.connectionPromise = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
51
src/classes/relay-set.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { relaysFromContactsEvent } from "../helpers/nostr/contacts";
|
||||
import { getRelaysFromMailbox } from "../helpers/nostr/mailbox";
|
||||
import { safeRelayUrl } from "../helpers/relay";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { RelayMode } from "./relay";
|
||||
|
||||
export default class RelaySet extends Set<string> {
|
||||
get urls() {
|
||||
return Array.from(this);
|
||||
}
|
||||
getRelays() {
|
||||
return this.urls.map((url) => relayPoolService.requestRelay(url, false));
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new RelaySet(this);
|
||||
}
|
||||
merge(src: Iterable<string>): this {
|
||||
for (const url of src) this.add(url);
|
||||
return this;
|
||||
}
|
||||
|
||||
static from(...sources: (Iterable<string> | undefined)[]) {
|
||||
const set = new RelaySet();
|
||||
for (const src of sources) {
|
||||
if (!src) continue;
|
||||
for (const url of src) {
|
||||
const safe = safeRelayUrl(url);
|
||||
if (safe) set.add(safe);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
static fromNIP65Event(event: NostrEvent, mode: RelayMode = RelayMode.ALL) {
|
||||
return new RelaySet(
|
||||
getRelaysFromMailbox(event)
|
||||
.filter((r) => r.mode & mode)
|
||||
.map((r) => r.url),
|
||||
);
|
||||
}
|
||||
|
||||
static fromContactsEvent(contacts: NostrEvent, mode: RelayMode = RelayMode.ALL) {
|
||||
return new RelaySet(
|
||||
relaysFromContactsEvent(contacts)
|
||||
.filter((r) => r.mode & mode)
|
||||
.map((r) => r.url),
|
||||
);
|
||||
}
|
||||
}
|
6
src/classes/relay.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum RelayMode {
|
||||
NONE = 0,
|
||||
READ = 1,
|
||||
WRITE = 2,
|
||||
ALL = 1 | 2,
|
||||
}
|
165
src/classes/signers/amber-signer.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { EventTemplate, NostrEvent, VerifiedEvent, getEventHash, nip19, verifyEvent } from "nostr-tools";
|
||||
|
||||
import createDefer, { Deferred } from "../deferred";
|
||||
import { getPubkeyFromDecodeResult, isHex, isHexKey } from "../../helpers/nip19";
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
|
||||
export default class AmberSigner implements Nip07Signer {
|
||||
static SUPPORTED = navigator.userAgent.includes("Android") && navigator.clipboard && navigator.clipboard.readText;
|
||||
private pendingRequest: Deferred<string> | null = null;
|
||||
public pubkey?: string;
|
||||
|
||||
verifyEvent: typeof verifyEvent = verifyEvent;
|
||||
|
||||
nip04?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
nip44?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor() {
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
|
||||
this.nip04 = {
|
||||
encrypt: this.nip04Encrypt.bind(this),
|
||||
decrypt: this.nip04Decrypt.bind(this),
|
||||
};
|
||||
this.nip44 = {
|
||||
encrypt: this.nip44Encrypt.bind(this),
|
||||
decrypt: this.nip44Decrypt.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
if (!this.pendingRequest || !navigator.clipboard) return;
|
||||
|
||||
// read the result from the clipboard
|
||||
setTimeout(() => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((result) => this.pendingRequest?.resolve(result))
|
||||
.catch((e) => this.pendingRequest?.reject(e));
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
private async intentRequest(intent: string) {
|
||||
this.rejectPending();
|
||||
const request = createDefer<string>();
|
||||
window.open(intent, "_blank");
|
||||
// NOTE: wait 500ms before setting the pending request since the visibilitychange event fires as soon as window.open is called
|
||||
setTimeout(() => {
|
||||
this.pendingRequest = request;
|
||||
}, 500);
|
||||
const result = await request;
|
||||
if (result.length === 0) throw new Error("Empty clipboard");
|
||||
return result;
|
||||
}
|
||||
|
||||
rejectPending() {
|
||||
if (this.pendingRequest) {
|
||||
this.pendingRequest.reject("Canceled");
|
||||
this.pendingRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
private checkSupport() {
|
||||
if (!AmberSigner.SUPPORTED) throw new Error("Cant use Amber on non-Android device");
|
||||
}
|
||||
|
||||
public async getPublicKey() {
|
||||
this.checkSupport();
|
||||
if (this.pubkey) return this.pubkey;
|
||||
|
||||
const result = await this.intentRequest(AmberSigner.createGetPublicKeyIntent());
|
||||
if (isHexKey(result)) return result;
|
||||
else if (result.startsWith("npub") || result.startsWith("nprofile")) {
|
||||
const decode = nip19.decode(result);
|
||||
const pubkey = getPubkeyFromDecodeResult(decode);
|
||||
if (!pubkey) throw new Error("Expected npub from clipboard");
|
||||
this.pubkey = pubkey;
|
||||
return pubkey;
|
||||
}
|
||||
throw new Error("Expected clipboard to have pubkey");
|
||||
}
|
||||
|
||||
public async signEvent(draft: EventTemplate & { pubkey?: string }): Promise<VerifiedEvent> {
|
||||
this.checkSupport();
|
||||
const pubkey = draft.pubkey || this.pubkey;
|
||||
if (!pubkey) throw new Error("Unknown signer pubkey");
|
||||
|
||||
const draftWithId = { ...draft, id: getEventHash({ ...draft, pubkey }) };
|
||||
const sig = await this.intentRequest(AmberSigner.createSignEventIntent(draftWithId));
|
||||
if (!isHex(sig)) throw new Error("Expected hex signature");
|
||||
|
||||
const event: NostrEvent = { ...draftWithId, sig, pubkey };
|
||||
if (!this.verifyEvent(event)) throw new Error("Invalid signature");
|
||||
return event;
|
||||
}
|
||||
|
||||
// NIP-04
|
||||
public async nip04Encrypt(pubkey: string, plaintext: string): Promise<string> {
|
||||
this.checkSupport();
|
||||
const data = await this.intentRequest(AmberSigner.createNip04EncryptIntent(pubkey, plaintext));
|
||||
return data;
|
||||
}
|
||||
public async nip04Decrypt(pubkey: string, data: string): Promise<string> {
|
||||
this.checkSupport();
|
||||
const plaintext = await this.intentRequest(AmberSigner.createNip04DecryptIntent(pubkey, data));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// NIP-44
|
||||
public async nip44Encrypt(pubkey: string, plaintext: string): Promise<string> {
|
||||
this.checkSupport();
|
||||
const data = await this.intentRequest(AmberSigner.createNip44EncryptIntent(pubkey, plaintext));
|
||||
return data;
|
||||
}
|
||||
public async nip44Decrypt(pubkey: string, data: string): Promise<string> {
|
||||
this.checkSupport();
|
||||
const plaintext = await this.intentRequest(AmberSigner.createNip44DecryptIntent(pubkey, data));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// static methods
|
||||
static createGetPublicKeyIntent() {
|
||||
return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`;
|
||||
}
|
||||
static createSignEventIntent(draft: EventTemplate) {
|
||||
return `intent:${encodeURIComponent(
|
||||
JSON.stringify(draft),
|
||||
)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
|
||||
}
|
||||
static createNip04EncryptIntent(pubkey: string, plainText: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
plainText,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_encrypt;end`;
|
||||
}
|
||||
static createNip04DecryptIntent(pubkey: string, ciphertext: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
ciphertext,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_decrypt;end`;
|
||||
}
|
||||
static createNip44EncryptIntent(pubkey: string, plainText: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
plainText,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip44_encrypt;end`;
|
||||
}
|
||||
static createNip44DecryptIntent(pubkey: string, ciphertext: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
ciphertext,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip44_decrypt;end`;
|
||||
}
|
||||
}
|
331
src/classes/signers/nostr-connect-signer.ts
Normal file
@ -0,0 +1,331 @@
|
||||
import {
|
||||
EventTemplate,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
kinds,
|
||||
nip04,
|
||||
NostrEvent,
|
||||
verifyEvent,
|
||||
} from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
|
||||
import MultiSubscription from "../multi-subscription";
|
||||
import { logger } from "../../helpers/debug";
|
||||
import createDefer, { Deferred } from "../deferred";
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
|
||||
export function isErrorResponse(response: any): response is NostrConnectErrorResponse {
|
||||
return !!response.error;
|
||||
}
|
||||
|
||||
export enum NostrConnectMethod {
|
||||
Connect = "connect",
|
||||
CreateAccount = "create_account",
|
||||
Disconnect = "disconnect",
|
||||
GetPublicKey = "get_pubic_key",
|
||||
SignEvent = "sign_event",
|
||||
Nip04Encrypt = "nip04_encrypt",
|
||||
Nip04Decrypt = "nip04_decrypt",
|
||||
Nip44Encrypt = "nip44_encrypt",
|
||||
Nip44Decrypt = "nip44_decrypt",
|
||||
}
|
||||
type RequestParams = {
|
||||
[NostrConnectMethod.Connect]: [string] | [string, string] | [string, string, string];
|
||||
[NostrConnectMethod.CreateAccount]: [string, string] | [string, string, string] | [string, string, string, string];
|
||||
[NostrConnectMethod.Disconnect]: [];
|
||||
[NostrConnectMethod.GetPublicKey]: [];
|
||||
[NostrConnectMethod.SignEvent]: [string];
|
||||
[NostrConnectMethod.Nip04Encrypt]: [string, string];
|
||||
[NostrConnectMethod.Nip04Decrypt]: [string, string];
|
||||
[NostrConnectMethod.Nip44Encrypt]: [string, string];
|
||||
[NostrConnectMethod.Nip44Decrypt]: [string, string];
|
||||
};
|
||||
type ResponseResults = {
|
||||
[NostrConnectMethod.Connect]: "ack";
|
||||
[NostrConnectMethod.CreateAccount]: string;
|
||||
[NostrConnectMethod.Disconnect]: "ack";
|
||||
[NostrConnectMethod.GetPublicKey]: string;
|
||||
[NostrConnectMethod.SignEvent]: string;
|
||||
[NostrConnectMethod.Nip04Encrypt]: string;
|
||||
[NostrConnectMethod.Nip04Decrypt]: string;
|
||||
[NostrConnectMethod.Nip44Encrypt]: string;
|
||||
[NostrConnectMethod.Nip44Decrypt]: string;
|
||||
};
|
||||
export type NostrConnectRequest<N extends NostrConnectMethod> = { id: string; method: N; params: RequestParams[N] };
|
||||
export type NostrConnectResponse<N extends NostrConnectMethod> = {
|
||||
id: string;
|
||||
result: ResponseResults[N];
|
||||
error?: string;
|
||||
};
|
||||
export type NostrConnectErrorResponse = {
|
||||
id: string;
|
||||
result: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export default class NostrConnectSigner implements Nip07Signer {
|
||||
sub: MultiSubscription;
|
||||
log = logger.extend("NostrConnectSigner");
|
||||
|
||||
isConnected = false;
|
||||
/** remote user pubkey */
|
||||
pubkey?: string;
|
||||
provider?: string;
|
||||
relays: string[];
|
||||
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
|
||||
verifyEvent: typeof verifyEvent = verifyEvent;
|
||||
|
||||
supportedMethods: NostrConnectMethod[] | undefined;
|
||||
|
||||
nip04?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
nip44?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor(pubkey?: string, relays: string[] = [], secretKey?: string, provider?: string) {
|
||||
this.sub = new MultiSubscription(`${pubkey || "unknown"}-nostr-connect`);
|
||||
this.pubkey = pubkey;
|
||||
this.relays = relays;
|
||||
this.provider = provider;
|
||||
|
||||
this.secretKey = secretKey || bytesToHex(generateSecretKey());
|
||||
this.publicKey = getPublicKey(hexToBytes(this.secretKey));
|
||||
|
||||
this.sub.onEvent.subscribe((e) => this.handleEvent(e));
|
||||
this.sub.setRelays(this.relays);
|
||||
this.sub.setFilters([
|
||||
{
|
||||
kinds: [kinds.NostrConnect],
|
||||
"#p": [this.publicKey],
|
||||
},
|
||||
]);
|
||||
|
||||
this.nip04 = {
|
||||
encrypt: this.nip04Encrypt.bind(this),
|
||||
decrypt: this.nip04Decrypt.bind(this),
|
||||
};
|
||||
this.nip44 = {
|
||||
encrypt: this.nip44Encrypt.bind(this),
|
||||
decrypt: this.nip44Decrypt.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
async open() {
|
||||
this.sub.open();
|
||||
await this.sub.waitForAllConnection();
|
||||
this.log("Connected to relays", this.relays);
|
||||
}
|
||||
close() {
|
||||
this.sub.close();
|
||||
}
|
||||
|
||||
handleAuthURL(url: string) {
|
||||
const popup = window.open(
|
||||
url,
|
||||
"auth",
|
||||
"width=400,height=600,resizable=no,status=no,location=no,toolbar=no,menubar=no",
|
||||
);
|
||||
}
|
||||
|
||||
private requests = new Map<string, Deferred<any>>();
|
||||
private auths = new Set<string>();
|
||||
async handleEvent(event: NostrEvent) {
|
||||
if (!this.verifyEvent(event)) return;
|
||||
if (this.provider && event.pubkey !== this.provider) return;
|
||||
|
||||
const to = event.tags.find((t) => t[0] === "p" && t[1])?.[1];
|
||||
if (!to) return;
|
||||
|
||||
try {
|
||||
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
|
||||
const response = JSON.parse(responseStr);
|
||||
|
||||
// handle client connections
|
||||
if (!this.pubkey && response.result === "ack") {
|
||||
this.log("Got ack response from", event.pubkey);
|
||||
this.pubkey = event.pubkey;
|
||||
this.sub.name = `${event.pubkey}-nostr-connect`;
|
||||
this.isConnected = true;
|
||||
this.listenPromise?.resolve(response.result);
|
||||
this.listenPromise = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.id) {
|
||||
const p = this.requests.get(response.id);
|
||||
if (!p) return;
|
||||
if (response.error) {
|
||||
this.log("Got Error", response.id, response.result, response.error);
|
||||
if (response.result === "auth_url") {
|
||||
if (!this.auths.has(response.id)) {
|
||||
this.auths.add(response.id);
|
||||
try {
|
||||
await this.handleAuthURL(response.error);
|
||||
} catch (e) {
|
||||
p.reject(e);
|
||||
}
|
||||
}
|
||||
} else p.reject(response);
|
||||
} else if (response.result) {
|
||||
this.log("Got Response", response.id, response.result);
|
||||
p.resolve(response.result);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
|
||||
if (!target) throw new Error("invalid target pubkey");
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [["p", target]],
|
||||
content,
|
||||
},
|
||||
hexToBytes(this.secretKey),
|
||||
);
|
||||
}
|
||||
private async makeRequest<T extends NostrConnectMethod>(
|
||||
method: T,
|
||||
params: RequestParams[T],
|
||||
kind = kinds.NostrConnect,
|
||||
): Promise<ResponseResults[T]> {
|
||||
if (!this.pubkey) throw new Error("pubkey not set");
|
||||
const id = nanoid(8);
|
||||
const request: NostrConnectRequest<T> = { id, method, params };
|
||||
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
|
||||
const event = this.createEvent(encrypted, this.pubkey, kind);
|
||||
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`, event);
|
||||
this.sub.publish(event);
|
||||
|
||||
const p = createDefer<ResponseResults[T]>();
|
||||
this.requests.set(id, p);
|
||||
return p;
|
||||
}
|
||||
private async makeAdminRequest<T extends NostrConnectMethod>(
|
||||
method: T,
|
||||
params: RequestParams[T],
|
||||
kind = 24133,
|
||||
): Promise<ResponseResults[T]> {
|
||||
if (!this.provider) throw new Error("Missing provider");
|
||||
const id = nanoid(8);
|
||||
const request: NostrConnectRequest<T> = { id, method, params };
|
||||
const encrypted = await nip04.encrypt(this.secretKey, this.provider, JSON.stringify(request));
|
||||
const event = this.createEvent(encrypted, this.provider, kind);
|
||||
this.log(`Sending admin request ${id} (${method}) ${JSON.stringify(params)}`, event);
|
||||
this.sub.publish(event);
|
||||
|
||||
const p = createDefer<ResponseResults[T]>();
|
||||
this.requests.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async connect(token?: string, permissions?: string[]) {
|
||||
if (!this.pubkey) throw new Error("pubkey not set");
|
||||
await this.open();
|
||||
try {
|
||||
const result = await this.makeRequest(NostrConnectMethod.Connect, [
|
||||
this.pubkey,
|
||||
token || "",
|
||||
permissions?.join(",") ?? "",
|
||||
]);
|
||||
this.isConnected = true;
|
||||
return result;
|
||||
} catch (e) {
|
||||
this.isConnected = false;
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
listenPromise: Deferred<"ack"> | null = null;
|
||||
listen(): Promise<"ack"> {
|
||||
if (this.pubkey) throw new Error("Cant listen if there is already a pubkey");
|
||||
this.open();
|
||||
this.listenPromise = createDefer();
|
||||
return this.listenPromise;
|
||||
}
|
||||
|
||||
async createAccount(name: string, domain: string, email?: string, permissions?: string[]) {
|
||||
await this.open();
|
||||
|
||||
try {
|
||||
const newPubkey = await this.makeAdminRequest(NostrConnectMethod.CreateAccount, [
|
||||
name,
|
||||
domain,
|
||||
email || "",
|
||||
permissions?.join(",") ?? "",
|
||||
]);
|
||||
this.pubkey = newPubkey;
|
||||
this.isConnected = true;
|
||||
return newPubkey;
|
||||
} catch (e) {
|
||||
this.isConnected = false;
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
ensureConnected() {
|
||||
if (!this.isConnected) return this.connect();
|
||||
}
|
||||
disconnect() {
|
||||
return this.makeRequest(NostrConnectMethod.Disconnect, []);
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
await this.ensureConnected();
|
||||
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
|
||||
}
|
||||
async signEvent(template: EventTemplate & { pubkey?: string }) {
|
||||
await this.ensureConnected();
|
||||
const eventString = await this.makeRequest(NostrConnectMethod.SignEvent, [JSON.stringify(template)]);
|
||||
const event = JSON.parse(eventString) as NostrEvent;
|
||||
if (!this.verifyEvent(event)) throw new Error("Invalid event");
|
||||
return event;
|
||||
}
|
||||
|
||||
// NIP-04
|
||||
async nip04Encrypt(pubkey: string, plaintext: string) {
|
||||
await this.ensureConnected();
|
||||
return this.makeRequest(NostrConnectMethod.Nip04Encrypt, [pubkey, plaintext]);
|
||||
}
|
||||
async nip04Decrypt(pubkey: string, ciphertext: string) {
|
||||
await this.ensureConnected();
|
||||
const plaintext = await this.makeRequest(NostrConnectMethod.Nip04Decrypt, [pubkey, ciphertext]);
|
||||
|
||||
// NOTE: not sure why this is here, best guess is some signer used to return results as '["plaintext"]'
|
||||
if (plaintext.startsWith('["') && plaintext.endsWith('"]')) return JSON.parse(plaintext)[0] as string;
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// NIP-44
|
||||
async nip44Encrypt(pubkey: string, plaintext: string) {
|
||||
await this.ensureConnected();
|
||||
return this.makeRequest(NostrConnectMethod.Nip44Encrypt, [pubkey, plaintext]);
|
||||
}
|
||||
async nip44Decrypt(pubkey: string, ciphertext: string) {
|
||||
await this.ensureConnected();
|
||||
const plaintext = await this.makeRequest(NostrConnectMethod.Nip44Decrypt, [pubkey, ciphertext]);
|
||||
|
||||
// NOTE: not sure why this is here, best guess is some signer used to return results as '["plaintext"]'
|
||||
if (plaintext.startsWith('["') && plaintext.endsWith('"]')) return JSON.parse(plaintext)[0] as string;
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
}
|
185
src/classes/signers/password-signer.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { EventTemplate, finalizeEvent, getPublicKey, nip04, nip44 } from "nostr-tools";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import { encrypt, decrypt } from "nostr-tools/nip49";
|
||||
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
import createDefer, { Deferred } from "../deferred";
|
||||
import db from "../../services/db";
|
||||
|
||||
async function getSalt() {
|
||||
let salt = await db.get("misc", "salt");
|
||||
if (salt) {
|
||||
return salt as Uint8Array;
|
||||
} else {
|
||||
const newSalt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
await db.put("misc", newSalt, "salt");
|
||||
return newSalt;
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
async function getKeyMaterial(password: string) {
|
||||
return await window.crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
|
||||
"deriveBits",
|
||||
"deriveKey",
|
||||
]);
|
||||
}
|
||||
|
||||
async function getEncryptionKey(password: string) {
|
||||
const salt = await getSalt();
|
||||
const keyMaterial = await getKeyMaterial(password);
|
||||
return await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async function subltCryptoEncryptSecKey(key: Uint8Array, password: string) {
|
||||
const encryptionKey = await getEncryptionKey(password);
|
||||
const encode = new TextEncoder();
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(96));
|
||||
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
encryptionKey,
|
||||
encode.encode(bytesToHex(key)),
|
||||
);
|
||||
|
||||
return {
|
||||
buffer: encrypted,
|
||||
iv,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async function subltCryptoDecryptSecKey(buffer: ArrayBuffer, iv: Uint8Array, password: string) {
|
||||
const encryptionKey = await getEncryptionKey(password);
|
||||
const decode = new TextDecoder();
|
||||
|
||||
try {
|
||||
const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, encryptionKey, buffer);
|
||||
const key = decode.decode(decrypted);
|
||||
return hexToBytes(key);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new Error("Failed to decrypt secret key");
|
||||
}
|
||||
}
|
||||
|
||||
export default class PasswordSigner implements Nip07Signer {
|
||||
key: Uint8Array | null = null;
|
||||
|
||||
// legacy
|
||||
buffer?: ArrayBuffer;
|
||||
iv?: Uint8Array;
|
||||
|
||||
ncryptsec?: string;
|
||||
|
||||
nip04?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
nip44?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
get unlocked() {
|
||||
return !!this.key;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.nip04 = {
|
||||
encrypt: this.nip04Encrypt.bind(this),
|
||||
decrypt: this.nip04Decrypt.bind(this),
|
||||
};
|
||||
this.nip44 = {
|
||||
encrypt: this.nip44Encrypt.bind(this),
|
||||
decrypt: this.nip44Decrypt.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
unlockPromise?: Deferred<void>;
|
||||
private requestUnlock() {
|
||||
if (this.key) return;
|
||||
if (this.unlockPromise) return this.unlockPromise;
|
||||
const p = createDefer<void>();
|
||||
this.unlockPromise = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
public async setPassword(password: string) {
|
||||
if (!this.key) throw new Error("Cant set password until unlocked");
|
||||
|
||||
// const { iv, buffer } = await subltCryptoEncryptSecKey(this.key, password);
|
||||
// this.iv = iv;
|
||||
// this.buffer = buffer;
|
||||
this.ncryptsec = encrypt(this.key, password);
|
||||
}
|
||||
|
||||
public async testPassword(password: string) {
|
||||
if (this.ncryptsec) {
|
||||
const key = decrypt(this.ncryptsec, password);
|
||||
if (!key) throw new Error("Failed to decrypt key");
|
||||
} else if (this.buffer && this.iv) {
|
||||
const key = await subltCryptoDecryptSecKey(this.buffer, this.iv, password);
|
||||
if (!key) throw new Error("Failed to decrypt key");
|
||||
} else throw new Error("Missing array buffer and iv");
|
||||
}
|
||||
|
||||
public async unlock(password: string) {
|
||||
if (this.key) return;
|
||||
|
||||
if (this.ncryptsec) {
|
||||
this.key = decrypt(this.ncryptsec, password);
|
||||
if (!this.key) throw new Error("Failed to decrypt key");
|
||||
} else if (this.buffer && this.iv) {
|
||||
this.key = await subltCryptoDecryptSecKey(this.buffer, this.iv, password);
|
||||
this.unlockPromise?.resolve();
|
||||
this.setPassword(password);
|
||||
} else throw new Error("Missing array buffer and iv");
|
||||
}
|
||||
|
||||
// public methods
|
||||
public async getPublicKey() {
|
||||
await this.requestUnlock();
|
||||
return getPublicKey(this.key!);
|
||||
}
|
||||
public async signEvent(event: EventTemplate) {
|
||||
await this.requestUnlock();
|
||||
return finalizeEvent(event, this.key!);
|
||||
}
|
||||
|
||||
// NIP-04
|
||||
async nip04Encrypt(pubkey: string, plaintext: string) {
|
||||
await this.requestUnlock();
|
||||
return nip04.encrypt(this.key!, pubkey, plaintext);
|
||||
}
|
||||
async nip04Decrypt(pubkey: string, ciphertext: string) {
|
||||
await this.requestUnlock();
|
||||
return nip04.decrypt(this.key!, pubkey, ciphertext);
|
||||
}
|
||||
|
||||
// NIP-44
|
||||
async nip44Encrypt(pubkey: string, plaintext: string) {
|
||||
await this.requestUnlock();
|
||||
return nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key!, pubkey));
|
||||
}
|
||||
async nip44Decrypt(pubkey: string, ciphertext: string) {
|
||||
await this.requestUnlock();
|
||||
return nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key!, pubkey));
|
||||
}
|
||||
}
|
263
src/classes/signers/serial-port-signer.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { EventTemplate, getEventHash, NostrEvent, verifyEvent } from "nostr-tools";
|
||||
import { base64 } from "@scure/base";
|
||||
import { randomBytes, hexToBytes } from "@noble/hashes/utils";
|
||||
import { Point } from "@noble/secp256k1";
|
||||
|
||||
import { logger } from "../../helpers/debug";
|
||||
import { Nip07Signer } from "../../types/nostr-extensions";
|
||||
import createDefer, { Deferred } from "../deferred";
|
||||
|
||||
type Callback = () => void;
|
||||
type DeviceOpts = {
|
||||
onConnect?: Callback;
|
||||
onDisconnect?: Callback;
|
||||
onError?: (err: Error) => void;
|
||||
onDone?: Callback;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function xOnlyToXY(p: string) {
|
||||
return Point.fromHex(p).toHex().substring(2);
|
||||
}
|
||||
|
||||
export const utf8Decoder = new TextDecoder("utf-8");
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
|
||||
export default class SerialPortSigner implements Nip07Signer {
|
||||
log = logger.extend("SerialSigner");
|
||||
writer: WritableStreamDefaultWriter<string> | null = null;
|
||||
pubkey?: string;
|
||||
|
||||
get isConnected() {
|
||||
return !!this.writer;
|
||||
}
|
||||
|
||||
verifyEvent: typeof verifyEvent = verifyEvent;
|
||||
nip04?:
|
||||
| {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor() {
|
||||
this.nip04 = {
|
||||
encrypt: this.nip04Encrypt.bind(this),
|
||||
decrypt: this.nip04Decrypt.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
lastCommand: Deferred<string> | null = null;
|
||||
async callMethodOnDevice(method: string, params: string[], opts: DeviceOpts = {}) {
|
||||
if (!SerialPortSigner.SUPPORTED) throw new Error("Serial devices are not supported");
|
||||
if (!this.writer) await this.connectToDevice(opts);
|
||||
|
||||
// only one command can be pending at any time
|
||||
// but each will only wait 6 seconds
|
||||
if (this.lastCommand) throw new Error("Previous command to device still pending!");
|
||||
const command = createDefer<string>();
|
||||
this.lastCommand = command;
|
||||
|
||||
// send actual command
|
||||
this.sendCommand(method, params);
|
||||
setTimeout(() => {
|
||||
command.reject(new Error("Device timeout"));
|
||||
if (this.lastCommand === command) this.lastCommand = null;
|
||||
}, 6000);
|
||||
|
||||
return this.lastCommand;
|
||||
}
|
||||
|
||||
async connectToDevice({ onConnect, onDisconnect, onError, onDone }: DeviceOpts): Promise<void> {
|
||||
let port: SerialPort = await navigator.serial.requestPort();
|
||||
let reader;
|
||||
|
||||
const startSerialPortReading = async () => {
|
||||
// reading responses
|
||||
while (port && port.readable) {
|
||||
const textDecoder = new window.TextDecoderStream();
|
||||
port.readable.pipeTo(textDecoder.writable);
|
||||
reader = textDecoder.readable.getReader();
|
||||
const readStringUntil = this.readFromSerialPort(reader);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await readStringUntil("\n");
|
||||
if (value) {
|
||||
const { method, data } = this.parseResponse(value);
|
||||
|
||||
// if (method === "/log") deviceLog(data);
|
||||
if (method === "/ping") this.log("Pong");
|
||||
|
||||
if (SerialPortSigner.PUBLIC_METHODS.indexOf(method) === -1) {
|
||||
// ignore /ping, /log responses
|
||||
continue;
|
||||
}
|
||||
|
||||
this.log("Received: ", method, data);
|
||||
|
||||
if (this.lastCommand) {
|
||||
this.lastCommand.resolve(data);
|
||||
this.lastCommand = null;
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
this.lastCommand = null;
|
||||
this.writer = null;
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this.writer = null;
|
||||
if (onError) onError(error);
|
||||
if (this.lastCommand) {
|
||||
this.lastCommand.reject(error);
|
||||
this.lastCommand = null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await port.open({ baudRate: 9600 });
|
||||
|
||||
// this `sleep()` is a hack, I know!
|
||||
// but `port.onconnect` is never called. I don't know why!
|
||||
await sleep(1000);
|
||||
startSerialPortReading();
|
||||
|
||||
const textEncoder = new window.TextEncoderStream();
|
||||
textEncoder.readable.pipeTo(port.writable);
|
||||
this.writer = textEncoder.writable.getWriter();
|
||||
|
||||
// send ping first
|
||||
await this.sendCommand(SerialPortSigner.METHOD_PING);
|
||||
await this.sendCommand(SerialPortSigner.METHOD_PING, [window.location.host]);
|
||||
|
||||
if (onConnect) onConnect();
|
||||
|
||||
port.addEventListener("disconnect", () => {
|
||||
this.log("Disconnected");
|
||||
this.lastCommand = null;
|
||||
this.writer = null;
|
||||
if (onDisconnect) onDisconnect();
|
||||
});
|
||||
}
|
||||
|
||||
async sendCommand(method: string, params: string[] = []) {
|
||||
if (!this.writer) return;
|
||||
this.log("Send command", method, params);
|
||||
const message = [method].concat(params).join(" ");
|
||||
await this.writer.write(message + "\n");
|
||||
}
|
||||
|
||||
private readFromSerialPort(reader: ReadableStreamDefaultReader<string>) {
|
||||
let partialChunk: string | undefined;
|
||||
let fulliness: string[] = [];
|
||||
|
||||
const readStringUntil = async (separator = "\n") => {
|
||||
if (fulliness.length) return { value: fulliness.shift()!.trim(), done: false };
|
||||
|
||||
const chunks = [];
|
||||
if (partialChunk) {
|
||||
// leftovers from previous read
|
||||
chunks.push(partialChunk);
|
||||
partialChunk = undefined;
|
||||
}
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) {
|
||||
const values = value.split(separator);
|
||||
// found one or more separators
|
||||
if (values.length > 1) {
|
||||
chunks.push(values.shift()); // first element
|
||||
partialChunk = values.pop(); // last element
|
||||
fulliness = values; // full lines
|
||||
return { value: chunks.join("").trim(), done: false };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
if (done) return { value: chunks.join("").trim(), done: true };
|
||||
}
|
||||
};
|
||||
return readStringUntil;
|
||||
}
|
||||
|
||||
private parseResponse(value: string) {
|
||||
const method = value.split(" ")[0];
|
||||
const data = value.substring(method.length).trim();
|
||||
|
||||
return { method, data };
|
||||
}
|
||||
|
||||
// NIP-04
|
||||
public async nip04Encrypt(pubkey: string, text: string) {
|
||||
const sharedSecretStr = await this.callMethodOnDevice(SerialPortSigner.METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
const sharedSecret = hexToBytes(sharedSecretStr);
|
||||
|
||||
let iv = Uint8Array.from(randomBytes(16));
|
||||
let plaintext = utf8Encoder.encode(text);
|
||||
let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["encrypt"]);
|
||||
let ciphertext = await crypto.subtle.encrypt({ name: "AES-CBC", iv }, cryptoKey, plaintext);
|
||||
let ctb64 = base64.encode(new Uint8Array(ciphertext));
|
||||
let ivb64 = base64.encode(new Uint8Array(iv.buffer));
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`;
|
||||
}
|
||||
public async nip04Decrypt(pubkey: string, data: string) {
|
||||
let [ctb64, ivb64] = data.split("?iv=");
|
||||
|
||||
const sharedSecretStr = await this.callMethodOnDevice(SerialPortSigner.METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
const sharedSecret = hexToBytes(sharedSecretStr);
|
||||
|
||||
let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["decrypt"]);
|
||||
let ciphertext = base64.decode(ctb64);
|
||||
let iv = base64.decode(ivb64);
|
||||
|
||||
let plaintext = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, ciphertext);
|
||||
|
||||
let text = utf8Decoder.decode(plaintext);
|
||||
return text;
|
||||
}
|
||||
|
||||
public async getPublicKey() {
|
||||
const pubkey = await this.callMethodOnDevice(SerialPortSigner.METHOD_PUBLIC_KEY, []);
|
||||
this.pubkey = pubkey;
|
||||
return pubkey;
|
||||
}
|
||||
public async signEvent(draft: EventTemplate & { pubkey?: string }) {
|
||||
const pubkey = draft.pubkey || this.pubkey;
|
||||
if (!pubkey) throw new Error("Unknown signer pubkey");
|
||||
|
||||
const draftWithId = { ...draft, id: getEventHash({ ...draft, pubkey }) };
|
||||
const sig = await this.callMethodOnDevice(SerialPortSigner.METHOD_SIGN_MESSAGE, [draftWithId.id]);
|
||||
|
||||
const event: NostrEvent = { ...draftWithId, sig, pubkey };
|
||||
if (!this.verifyEvent(event)) throw new Error("Invalid signature");
|
||||
return event;
|
||||
}
|
||||
|
||||
public ping() {
|
||||
this.sendCommand(SerialPortSigner.METHOD_PING, [window.location.host]);
|
||||
}
|
||||
|
||||
// static const
|
||||
static SUPPORTED = !!navigator.serial;
|
||||
|
||||
static METHOD_PING = "/ping";
|
||||
static METHOD_LOG = "/log";
|
||||
|
||||
static METHOD_SIGN_MESSAGE = "/sign-message";
|
||||
static METHOD_SHARED_SECRET = "/shared-secret";
|
||||
static METHOD_PUBLIC_KEY = "/public-key";
|
||||
|
||||
static PUBLIC_METHODS = [
|
||||
SerialPortSigner.METHOD_PUBLIC_KEY,
|
||||
SerialPortSigner.METHOD_SIGN_MESSAGE,
|
||||
SerialPortSigner.METHOD_SHARED_SECRET,
|
||||
];
|
||||
}
|
31
src/classes/signers/simple-signer.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { EventTemplate, finalizeEvent, generateSecretKey, getPublicKey, nip04, nip44 } from "nostr-tools";
|
||||
|
||||
export default class SimpleSigner {
|
||||
key: Uint8Array;
|
||||
constructor(key?: Uint8Array) {
|
||||
this.key = key || generateSecretKey();
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
return getPublicKey(this.key);
|
||||
}
|
||||
async signEvent(event: EventTemplate) {
|
||||
return finalizeEvent(event, this.key);
|
||||
}
|
||||
|
||||
nip04 = {
|
||||
encrypt: async (pubkey: string, plaintext: string) => nip04.encrypt(this.key, pubkey, plaintext),
|
||||
decrypt: async (pubkey: string, ciphertext: string) => nip04.decrypt(this.key, pubkey, ciphertext),
|
||||
};
|
||||
nip44 = {
|
||||
encrypt: async (pubkey: string, plaintext: string) =>
|
||||
nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
decrypt: async (pubkey: string, ciphertext: string) =>
|
||||
nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(this.key, pubkey)),
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.SimpleSigner = SimpleSigner;
|
||||
}
|
81
src/classes/subject.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import Observable from "zen-observable";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import ControlledObservable from "./controlled-observable";
|
||||
|
||||
/** An observable that is always open and stores the last value */
|
||||
export default class Subject<T> {
|
||||
private observable: ControlledObservable<T>;
|
||||
id = nanoid(8);
|
||||
value: T | undefined;
|
||||
|
||||
constructor(value?: T) {
|
||||
this.observable = new ControlledObservable();
|
||||
|
||||
this.value = value;
|
||||
this.subscribe = this.observable.subscribe.bind(this.observable);
|
||||
}
|
||||
|
||||
next(v: T) {
|
||||
this.value = v;
|
||||
this.observable.next(v);
|
||||
}
|
||||
error(err: any) {
|
||||
this.observable.error(err);
|
||||
}
|
||||
|
||||
[Symbol.observable]() {
|
||||
return this.observable;
|
||||
}
|
||||
subscribe: Observable<T>["subscribe"];
|
||||
|
||||
once(next: (value: T) => void) {
|
||||
const sub = this.subscribe((v) => {
|
||||
if (v !== undefined) {
|
||||
next(v);
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
return sub;
|
||||
}
|
||||
|
||||
map<R>(callback: (value: T) => R, defaultValue?: R): Subject<R> {
|
||||
const child = new Subject(defaultValue);
|
||||
|
||||
if (this.value !== undefined) {
|
||||
try {
|
||||
child.next(callback(this.value));
|
||||
} catch (e) {
|
||||
child.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.subscribe((value) => {
|
||||
try {
|
||||
child.next(callback(value));
|
||||
} catch (e) {
|
||||
child.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
connectWithMapper<R>(
|
||||
subject: Subject<R>,
|
||||
map: (value: R, next: (value: T) => void, current: T | undefined) => void,
|
||||
): ZenObservable.Subscription {
|
||||
return subject.subscribe((value) => {
|
||||
map(value, (v) => this.next(v), this.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PersistentSubject<T> extends Subject<T> {
|
||||
value: T;
|
||||
constructor(value: T) {
|
||||
super();
|
||||
this.value = value;
|
||||
}
|
||||
}
|
17
src/classes/super-map.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export default class SuperMap<Key, Value> extends Map<Key, Value> {
|
||||
newValue: (key: Key) => Value;
|
||||
|
||||
constructor(newValue: (key: Key) => Value) {
|
||||
super();
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
get(key: Key) {
|
||||
let value = super.get(key);
|
||||
if (value === undefined) {
|
||||
value = this.newValue(key);
|
||||
this.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
283
src/classes/timeline-loader.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Debugger } from "debug";
|
||||
import { Filter, NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import MultiSubscription from "./multi-subscription";
|
||||
import { PersistentSubject } from "./subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
import EventStore from "./event-store";
|
||||
import { isReplaceable } from "../helpers/nostr/event";
|
||||
import replaceableEventsService from "../services/replaceable-events";
|
||||
import { mergeFilter, isFilterEqual } from "../helpers/nostr/filter";
|
||||
import { localRelay } from "../services/local-relay";
|
||||
import SuperMap from "./super-map";
|
||||
import ChunkedRequest from "./chunked-request";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import Process from "./process";
|
||||
import AlignHorizontalCentre02 from "../components/icons/align-horizontal-centre-02";
|
||||
import processManager from "../services/process-manager";
|
||||
|
||||
const BLOCK_SIZE = 100;
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
export default class TimelineLoader {
|
||||
cursor = dayjs().unix();
|
||||
filters: Filter[] = [];
|
||||
relays: AbstractRelay[] = [];
|
||||
|
||||
events: EventStore;
|
||||
timeline = new PersistentSubject<NostrEvent[]>([]);
|
||||
loading = new PersistentSubject(false);
|
||||
complete = new PersistentSubject(false);
|
||||
|
||||
loadNextBlockBuffer = 2;
|
||||
eventFilter?: EventFilter;
|
||||
|
||||
name: string;
|
||||
process: Process;
|
||||
private log: Debugger;
|
||||
private subscription: MultiSubscription;
|
||||
|
||||
private cacheLoader: ChunkedRequest | null = null;
|
||||
private loaders = new Map<string, ChunkedRequest>();
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.process = new Process("TimelineLoader", this);
|
||||
this.process.name = name;
|
||||
this.process.icon = AlignHorizontalCentre02;
|
||||
|
||||
this.log = logger.extend("TimelineLoader:" + name);
|
||||
this.events = new EventStore(name);
|
||||
this.events.connect(replaceableEventsService.events, false);
|
||||
|
||||
this.subscription = new MultiSubscription(name);
|
||||
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
||||
this.subscription.onCacheEvent.subscribe((event) => this.handleEvent(event, true));
|
||||
this.process.addChild(this.subscription.process);
|
||||
|
||||
// update the timeline when there are new events
|
||||
this.events.onEvent.subscribe(this.throttleUpdateTimeline.bind(this));
|
||||
this.events.onDelete.subscribe(this.throttleUpdateTimeline.bind(this));
|
||||
this.events.onClear.subscribe(this.throttleUpdateTimeline.bind(this));
|
||||
|
||||
processManager.registerProcess(this.process);
|
||||
}
|
||||
|
||||
private throttleUpdateTimeline = _throttle(this.updateTimeline, 10);
|
||||
private updateTimeline() {
|
||||
if (this.eventFilter) {
|
||||
const filter = this.eventFilter;
|
||||
this.timeline.next(this.events.getSortedEvents().filter((e) => filter(e, this.events)));
|
||||
} else this.timeline.next(this.events.getSortedEvents());
|
||||
}
|
||||
|
||||
private seenInCache = new Set<string>();
|
||||
private handleEvent(event: NostrEvent, fromCache = false) {
|
||||
// if this is a replaceable event, mirror it over to the replaceable event service
|
||||
if (isReplaceable(event.kind)) replaceableEventsService.handleEvent(event);
|
||||
|
||||
this.events.addEvent(event);
|
||||
if (!fromCache && localRelay && !this.seenInCache.has(event.id)) localRelay.publish(event);
|
||||
|
||||
if (fromCache) this.seenInCache.add(event.id);
|
||||
}
|
||||
private handleChunkFinished() {
|
||||
this.updateLoading();
|
||||
this.updateComplete();
|
||||
}
|
||||
|
||||
private chunkLoaderSubs = new SuperMap<ChunkedRequest, ZenObservable.Subscription[]>(() => []);
|
||||
private connectToChunkLoader(loader: ChunkedRequest) {
|
||||
this.process.addChild(loader.process);
|
||||
|
||||
this.events.connect(loader.events);
|
||||
const subs = this.chunkLoaderSubs.get(loader);
|
||||
subs.push(loader.onChunkFinish.subscribe(this.handleChunkFinished.bind(this)));
|
||||
}
|
||||
private disconnectFromChunkLoader(loader: ChunkedRequest) {
|
||||
loader.destroy();
|
||||
this.events.disconnect(loader.events);
|
||||
const subs = this.chunkLoaderSubs.get(loader);
|
||||
for (const sub of subs) sub.unsubscribe();
|
||||
this.chunkLoaderSubs.delete(loader);
|
||||
}
|
||||
|
||||
setFilters(filters: Filter[]) {
|
||||
if (isFilterEqual(this.filters, filters)) return;
|
||||
|
||||
this.log("Set filters", filters);
|
||||
|
||||
// recreate all chunk loaders
|
||||
for (const relay of this.relays) {
|
||||
const loader = this.loaders.get(relay.url);
|
||||
if (loader) {
|
||||
this.disconnectFromChunkLoader(loader);
|
||||
this.loaders.delete(relay.url);
|
||||
}
|
||||
|
||||
const chunkLoader = new ChunkedRequest(
|
||||
relayPoolService.requestRelay(relay.url),
|
||||
filters,
|
||||
this.log.extend(relay.url),
|
||||
);
|
||||
this.loaders.set(relay.url, chunkLoader);
|
||||
this.connectToChunkLoader(chunkLoader);
|
||||
}
|
||||
|
||||
// set filters
|
||||
this.filters = filters;
|
||||
|
||||
// recreate cache chunk loader
|
||||
if (this.cacheLoader) this.disconnectFromChunkLoader(this.cacheLoader);
|
||||
if (localRelay) {
|
||||
this.cacheLoader = new ChunkedRequest(localRelay, this.filters, this.log.extend("cache-relay"));
|
||||
this.connectToChunkLoader(this.cacheLoader);
|
||||
}
|
||||
|
||||
// update the live subscription query map and add limit
|
||||
this.subscription.setFilters(mergeFilter(filters, { limit: BLOCK_SIZE / 2 }));
|
||||
}
|
||||
|
||||
setRelays(relays: Iterable<string | URL | AbstractRelay>) {
|
||||
const newRelays = relayPoolService.getRelays(relays);
|
||||
|
||||
// remove chunk loaders
|
||||
for (const relay of newRelays) {
|
||||
const loader = this.loaders.get(relay.url);
|
||||
if (!loader) continue;
|
||||
if (!this.relays.includes(relay)) {
|
||||
this.disconnectFromChunkLoader(loader);
|
||||
this.loaders.delete(relay.url);
|
||||
}
|
||||
}
|
||||
|
||||
// create chunk loaders only if filters are set
|
||||
if (this.filters.length > 0) {
|
||||
for (const relay of newRelays) {
|
||||
if (!this.loaders.has(relay.url)) {
|
||||
const loader = new ChunkedRequest(relay, this.filters, this.log.extend(relay.url));
|
||||
this.loaders.set(relay.url, loader);
|
||||
this.connectToChunkLoader(loader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.relays = relayPoolService.getRelays(relays);
|
||||
this.process.relays = new Set(this.relays);
|
||||
|
||||
// update live subscription
|
||||
this.subscription.setRelays(this.relays);
|
||||
}
|
||||
|
||||
setEventFilter(filter?: EventFilter) {
|
||||
this.eventFilter = filter;
|
||||
this.updateTimeline();
|
||||
}
|
||||
setCursor(cursor: number) {
|
||||
this.cursor = cursor;
|
||||
this.triggerChunkLoad();
|
||||
}
|
||||
|
||||
private getAllLoaders() {
|
||||
return this.cacheLoader ? [...this.loaders.values(), this.cacheLoader] : Array.from(this.loaders.values());
|
||||
}
|
||||
|
||||
triggerChunkLoad() {
|
||||
let triggeredLoad = false;
|
||||
const loaders = this.getAllLoaders();
|
||||
|
||||
for (const loader of loaders) {
|
||||
// skip loader if its already loading or complete
|
||||
if (loader.complete || loader.loading) continue;
|
||||
|
||||
const event = loader.getLastEvent(this.loadNextBlockBuffer, this.eventFilter);
|
||||
if (!event || event.created_at >= this.cursor) {
|
||||
loader.loadNextChunk();
|
||||
triggeredLoad = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (triggeredLoad) this.updateLoading();
|
||||
}
|
||||
loadAllNextChunks() {
|
||||
let triggeredLoad = false;
|
||||
const loaders = this.getAllLoaders();
|
||||
|
||||
for (const loader of loaders) {
|
||||
// skip loader if its already loading or complete
|
||||
if (loader.complete || loader.loading) continue;
|
||||
|
||||
loader.loadNextChunk();
|
||||
triggeredLoad = true;
|
||||
}
|
||||
|
||||
if (triggeredLoad) this.updateLoading();
|
||||
}
|
||||
|
||||
private updateLoading() {
|
||||
const loaders = this.getAllLoaders();
|
||||
|
||||
for (const loader of loaders) {
|
||||
if (loader.loading) {
|
||||
if (!this.loading.value) {
|
||||
this.loading.next(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.loading.value) this.loading.next(false);
|
||||
}
|
||||
private updateComplete() {
|
||||
const loaders = this.getAllLoaders();
|
||||
|
||||
for (const loader of loaders) {
|
||||
if (!loader.complete) {
|
||||
this.complete.next(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return this.complete.next(true);
|
||||
}
|
||||
open() {
|
||||
this.process.active = true;
|
||||
this.subscription.open();
|
||||
}
|
||||
close() {
|
||||
this.process.active = false;
|
||||
this.subscription.close();
|
||||
}
|
||||
|
||||
forgetEvents() {
|
||||
this.events.clear();
|
||||
this.timeline.next([]);
|
||||
this.subscription.forgetEvents();
|
||||
}
|
||||
reset() {
|
||||
this.cursor = dayjs().unix();
|
||||
const loaders = this.getAllLoaders();
|
||||
for (const loader of loaders) this.disconnectFromChunkLoader(loader);
|
||||
this.loaders.clear();
|
||||
this.cacheLoader = null;
|
||||
this.forgetEvents();
|
||||
}
|
||||
|
||||
/** close the subscription and remove any event listeners for this timeline */
|
||||
destroy() {
|
||||
this.close();
|
||||
|
||||
const loaders = this.getAllLoaders();
|
||||
for (const loader of loaders) this.disconnectFromChunkLoader(loader);
|
||||
this.loaders.clear();
|
||||
this.cacheLoader = null;
|
||||
|
||||
this.subscription.destroy();
|
||||
|
||||
this.events.cleanup();
|
||||
this.process.remove();
|
||||
processManager.unregisterProcess(this.process);
|
||||
}
|
||||
}
|
157
src/classes/webrtc/nostr-webrtc-broker.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { SubCloser } from "nostr-tools/abstract-pool";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { generateSecretKey, nip19, NostrEvent } from "nostr-tools";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import NostrWebRTCPeer, { Pool, RTCDescriptionEventKind, Signer } from "./nostr-webrtc-peer";
|
||||
import { isHex } from "../../helpers/nip19";
|
||||
import { logger } from "../../helpers/debug";
|
||||
import SimpleSigner from "../signers/simple-signer";
|
||||
|
||||
type EventMap = {
|
||||
call: [NostrEvent];
|
||||
};
|
||||
|
||||
export default class NostrWebRtcBroker extends EventEmitter<EventMap> {
|
||||
log = logger.extend("NostrWebRtcBroker");
|
||||
signer: Signer;
|
||||
pool: Pool;
|
||||
defaultRelays: string[];
|
||||
iceServers: RTCIceServer[] = [];
|
||||
|
||||
peers = new Map<string, NostrWebRTCPeer>();
|
||||
signers = new Map<string, Signer>();
|
||||
relays = new Map<string, string[]>();
|
||||
|
||||
constructor(signer: Signer, pool: Pool, relays: string[]) {
|
||||
super();
|
||||
this.signer = signer;
|
||||
this.pool = pool;
|
||||
this.defaultRelays = relays;
|
||||
}
|
||||
|
||||
getConnection(pubkey: string) {
|
||||
return this.peers.get(pubkey);
|
||||
}
|
||||
|
||||
async requestConnection(uri: string) {
|
||||
const { pubkey, relays, key } = NostrWebRtcBroker.parseNostrWebRtcURI(uri);
|
||||
|
||||
const cached = this.peers.get(pubkey);
|
||||
if (cached) return cached;
|
||||
|
||||
this.log(`Creating new connection for ${pubkey}`);
|
||||
|
||||
// set signer
|
||||
let signer = this.signer;
|
||||
if (key) {
|
||||
signer = new SimpleSigner(key);
|
||||
this.signers.set(pubkey, signer);
|
||||
}
|
||||
|
||||
// set relays
|
||||
if (relays.length > 0) this.relays.set(pubkey, relays);
|
||||
else this.relays.set(pubkey, this.defaultRelays);
|
||||
|
||||
const peer = new NostrWebRTCPeer(
|
||||
signer,
|
||||
this.pool,
|
||||
relays.length > 0 ? relays : this.defaultRelays,
|
||||
this.iceServers,
|
||||
);
|
||||
this.peers.set(pubkey, peer);
|
||||
await peer.makeCall(pubkey);
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
setPeerSigner(pubkey: string, signer: Signer) {
|
||||
this.signers.set(pubkey, signer);
|
||||
}
|
||||
|
||||
async answerCall(event: NostrEvent): Promise<NostrWebRTCPeer> {
|
||||
if (this.peers.has(event.pubkey)) throw new Error("Already have a peer connection for this pubkey");
|
||||
|
||||
// set signer
|
||||
let signer = this.signers.get(event.pubkey);
|
||||
if (!signer) {
|
||||
signer = this.signer;
|
||||
this.signers.set(event.pubkey, signer);
|
||||
}
|
||||
|
||||
const peer = new NostrWebRTCPeer(signer, this.pool, this.defaultRelays, this.iceServers);
|
||||
this.peers.set(event.pubkey, peer);
|
||||
await peer.answerCall(event);
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
closeConnection(pubkey: string) {
|
||||
const peer = this.peers.get(pubkey);
|
||||
if (peer) {
|
||||
this.log(`Hanging up connection to ${pubkey}`);
|
||||
peer.hangup();
|
||||
this.peers.delete(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
listening = false;
|
||||
subscription?: SubCloser;
|
||||
|
||||
async listenForCalls() {
|
||||
if (this.listening) throw new Error("Already listening");
|
||||
|
||||
this.log("Listening for calls");
|
||||
|
||||
this.listening = true;
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.defaultRelays,
|
||||
[{ kinds: [RTCDescriptionEventKind], "#p": [await this.signer.getPublicKey()], since: dayjs().unix() }],
|
||||
{
|
||||
onevent: (event) => {
|
||||
this.emit("call", event);
|
||||
},
|
||||
onclose: () => {
|
||||
this.listening = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (!this.listening) return;
|
||||
|
||||
this.log("Stop listening for calls");
|
||||
|
||||
if (this.subscription) this.subscription.close();
|
||||
this.subscription = undefined;
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
static parseNostrWebRtcURI(uri: string | URL) {
|
||||
const url = typeof uri === "string" ? new URL(uri) : uri;
|
||||
if (url.protocol !== "webrtc+nostr:") throw new Error("Incorrect protocol");
|
||||
const parsedPath = nip19.decode(url.pathname);
|
||||
const keyParam = url.searchParams.get("key");
|
||||
const relays = url.searchParams.getAll("relay");
|
||||
if (parsedPath.type !== "npub") throw new Error("Incorrect npub");
|
||||
const pubkey = parsedPath.data;
|
||||
if (keyParam && !isHex(keyParam)) throw new Error("Key must be in hex format");
|
||||
const key = keyParam ? hexToBytes(keyParam) : null;
|
||||
return { pubkey, key, relays };
|
||||
}
|
||||
|
||||
static createNostrWebRtcURI(pubkey: string, relays: string[], key?: Uint8Array | boolean) {
|
||||
const uri = new URL(`webrtc+nostr:${nip19.npubEncode(pubkey)}`);
|
||||
for (const relay of relays) uri.searchParams.append("relay", relay);
|
||||
if (key === true) uri.searchParams.append("key", bytesToHex(generateSecretKey()));
|
||||
else if (key instanceof Uint8Array) uri.searchParams.append("key", bytesToHex(key));
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.NostrWebRtcBroker = NostrWebRtcBroker;
|
||||
}
|
302
src/classes/webrtc/nostr-webrtc-peer.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
import { Debugger } from "debug";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import dayjs from "dayjs";
|
||||
import { EventTemplate, Filter, NostrEvent } from "nostr-tools";
|
||||
import { SubCloser, SubscribeManyParams } from "nostr-tools/abstract-pool";
|
||||
import { logger } from "../../helpers/debug";
|
||||
|
||||
export const RTCDescriptionEventKind = 25050;
|
||||
export const RTCICEEventKind = 25051;
|
||||
|
||||
export type Signer = {
|
||||
getPublicKey: () => Promise<string> | string;
|
||||
signEvent: (event: EventTemplate) => Promise<NostrEvent> | NostrEvent;
|
||||
nip44: {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Pool = {
|
||||
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser;
|
||||
publish(relays: string[], event: NostrEvent): Promise<string>[];
|
||||
};
|
||||
|
||||
type EventMap = {
|
||||
connected: [];
|
||||
disconnected: [];
|
||||
message: [string];
|
||||
};
|
||||
|
||||
export default class NostrWebRTCPeer extends EventEmitter<EventMap> {
|
||||
log: Debugger;
|
||||
signer: Signer;
|
||||
pool: Pool;
|
||||
peer?: string;
|
||||
relays: string[] = [];
|
||||
iceServers: RTCIceServer[] = [];
|
||||
|
||||
connection: RTCPeerConnection;
|
||||
channel?: RTCDataChannel;
|
||||
|
||||
subscription?: SubCloser;
|
||||
|
||||
async isCaller() {
|
||||
if (!this.offerEvent) return null;
|
||||
return (await this.signer.getPublicKey()) === this.offerEvent?.pubkey;
|
||||
}
|
||||
get offer() {
|
||||
return this.connection?.localDescription;
|
||||
}
|
||||
offerEvent?: NostrEvent;
|
||||
get answer() {
|
||||
return this.connection?.remoteDescription;
|
||||
}
|
||||
answerEvent?: NostrEvent;
|
||||
|
||||
private candidateQueue: RTCIceCandidateInit[] = [];
|
||||
|
||||
constructor(signer: Signer, pool: Pool, relays?: string[], iceServers?: RTCIceServer[]) {
|
||||
super();
|
||||
this.log = logger.extend(`NostrWebRTCPeer`);
|
||||
this.signer = signer;
|
||||
this.pool = pool;
|
||||
|
||||
if (iceServers) this.iceServers = iceServers;
|
||||
if (relays) this.relays = relays;
|
||||
|
||||
// create connection
|
||||
this.connection = new RTCPeerConnection({ iceServers: this.iceServers });
|
||||
this.log("Created local connection");
|
||||
|
||||
this.connection.onicecandidate = async ({ candidate }) => {
|
||||
if (candidate) {
|
||||
this.candidateQueue.push(candidate.toJSON());
|
||||
} else this.flushCandidateQueue();
|
||||
};
|
||||
this.connection.onicegatheringstatechange = this.flushCandidateQueue.bind(this);
|
||||
this.connection.onconnectionstatechange = (event) => {
|
||||
switch (this.connection?.connectionState) {
|
||||
case "connected":
|
||||
this.emit("connected");
|
||||
break;
|
||||
case "disconnected":
|
||||
this.emit("disconnected");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// receive data channel
|
||||
this.connection.ondatachannel = ({ channel }) => {
|
||||
this.log("Got data channel", channel.id, channel.label);
|
||||
|
||||
if (channel.label !== "nostr") return;
|
||||
|
||||
this.channel = channel;
|
||||
this.channel.onclose = this.onChannelStateChange.bind(this);
|
||||
this.channel.onopen = this.onChannelStateChange.bind(this);
|
||||
this.channel.onmessage = this.handleChannelMessage.bind(this);
|
||||
};
|
||||
}
|
||||
|
||||
private async flushCandidateQueue() {
|
||||
if (this.connection?.iceGatheringState !== "complete") return;
|
||||
|
||||
if (this.offerEvent && this.answerEvent && this.peer && this.candidateQueue.length > 0) {
|
||||
const cipherText = await this.signer.nip44.encrypt(this.peer, JSON.stringify(this.candidateQueue));
|
||||
const iceEvent = await this.signer.signEvent({
|
||||
kind: RTCICEEventKind,
|
||||
content: cipherText,
|
||||
tags: [["e", this.offerEvent.id]],
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log(`Publishing ${this.candidateQueue.length} ICE candidates`);
|
||||
await this.pool.publish(this.relays, iceEvent);
|
||||
this.candidateQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
async makeCall(peer: string) {
|
||||
if (this.peer) throw new Error("Already calling peer");
|
||||
|
||||
const pc = this.connection;
|
||||
|
||||
this.channel = pc.createDataChannel("nostr", { ordered: true });
|
||||
this.channel.onopen = this.onChannelStateChange.bind(this);
|
||||
this.channel.onclose = this.onChannelStateChange.bind(this);
|
||||
this.channel.onmessage = this.handleChannelMessage.bind(this);
|
||||
|
||||
this.log(`Making call to ${peer} `);
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
const cipherText = await this.signer.nip44.encrypt(peer, JSON.stringify(offer));
|
||||
const offerEvent = await this.signer.signEvent({
|
||||
kind: RTCDescriptionEventKind,
|
||||
content: cipherText,
|
||||
tags: [["p", peer], ...this.relays.map((r) => ["relay", r])],
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log("Created offer");
|
||||
|
||||
// listen for answers and ice events
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.relays,
|
||||
[
|
||||
{
|
||||
kinds: [RTCDescriptionEventKind, RTCICEEventKind],
|
||||
"#e": [offerEvent.id],
|
||||
authors: [peer],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent: async (event: NostrEvent) => {
|
||||
if (!this.offerEvent) return;
|
||||
if (!event.tags.some((t) => t[0] === "e" && t[1] === this.offerEvent?.id)) return;
|
||||
|
||||
console.log(event);
|
||||
|
||||
switch (event.kind) {
|
||||
case RTCDescriptionEventKind:
|
||||
await this.handleAnswer(event);
|
||||
// got answer, send ICE candidates
|
||||
await this.flushCandidateQueue();
|
||||
break;
|
||||
case RTCICEEventKind:
|
||||
await this.handleICEEvent(event);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.log("Signaling subscription closed");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.peer = peer;
|
||||
|
||||
this.log("Publishing event", offerEvent.id);
|
||||
await this.pool.publish(this.relays, offerEvent);
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
this.offerEvent = offerEvent;
|
||||
}
|
||||
|
||||
async handleAnswer(event: NostrEvent) {
|
||||
const pc = this.connection;
|
||||
|
||||
if (!pc.localDescription) throw new Error("Got answer without offering");
|
||||
|
||||
const plaintext = await this.signer.nip44.decrypt(event.pubkey, event.content);
|
||||
const answer = JSON.parse(plaintext) as RTCSessionDescriptionInit;
|
||||
if (answer.type !== "answer") throw new Error("Unexpected rtc description type");
|
||||
|
||||
this.log("Got answer");
|
||||
|
||||
await pc.setRemoteDescription(answer);
|
||||
|
||||
this.answerEvent = event;
|
||||
}
|
||||
|
||||
async answerCall(event: NostrEvent) {
|
||||
const pc = this.connection;
|
||||
|
||||
this.log(`Answering call ${event.id} from ${event.pubkey}`);
|
||||
|
||||
const plaintext = await this.signer.nip44.decrypt(event.pubkey, event.content);
|
||||
const offer = JSON.parse(plaintext) as RTCSessionDescriptionInit;
|
||||
if (offer.type !== "offer") throw new Error("Unexpected rtc description type");
|
||||
|
||||
this.relays = event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]);
|
||||
this.log(`Switching to callers signaling relays`, this.relays);
|
||||
|
||||
await pc.setRemoteDescription(offer);
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
const cipherText = await this.signer.nip44.encrypt(event.pubkey, JSON.stringify(answer));
|
||||
const answerEvent = await this.signer.signEvent({
|
||||
kind: RTCDescriptionEventKind,
|
||||
content: cipherText,
|
||||
tags: [
|
||||
["p", event.pubkey],
|
||||
["e", event.id],
|
||||
],
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
this.log("Created answer");
|
||||
|
||||
this.peer = event.pubkey;
|
||||
this.offerEvent = event;
|
||||
|
||||
// listen for ice events
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.relays,
|
||||
[{ kinds: [RTCICEEventKind], "#e": [event.id], authors: [event.pubkey] }],
|
||||
{
|
||||
onevent: async (event) => {
|
||||
if (!this.offerEvent) return;
|
||||
if (!event.tags.some((t) => t[0] === "e" && t[1] === this.offerEvent?.id)) return;
|
||||
|
||||
switch (event.kind) {
|
||||
case RTCICEEventKind:
|
||||
await this.handleICEEvent(event);
|
||||
break;
|
||||
}
|
||||
},
|
||||
onclose: () => {
|
||||
this.log("Signaling subscription closed");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.log("Publishing event", answerEvent.id);
|
||||
|
||||
await this.pool.publish(this.relays, answerEvent);
|
||||
await pc.setLocalDescription(answer);
|
||||
this.answerEvent = answerEvent;
|
||||
|
||||
// answered call, send ICE candidates
|
||||
await this.flushCandidateQueue();
|
||||
}
|
||||
|
||||
private async handleICEEvent(event: NostrEvent) {
|
||||
if (!this.connection) throw new Error("Got ICE event without connection");
|
||||
const pc = this.connection;
|
||||
|
||||
const plaintext = await this.signer.nip44.decrypt(event.pubkey, event.content);
|
||||
const candidates = JSON.parse(plaintext) as RTCIceCandidateInit[];
|
||||
|
||||
this.log(`Got ${candidates.length} candidates`);
|
||||
|
||||
for (let candidate of candidates) {
|
||||
await pc.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private onChannelStateChange() {
|
||||
const readyState = this.channel?.readyState;
|
||||
console.log("Send channel state is: " + readyState);
|
||||
}
|
||||
|
||||
private handleChannelMessage(event: MessageEvent<any>) {
|
||||
if (typeof event.data === "string") this.emit("message", event.data);
|
||||
}
|
||||
|
||||
send(message: string) {
|
||||
this.channel?.send(message);
|
||||
}
|
||||
|
||||
hangup() {
|
||||
this.log("Closing data channel");
|
||||
if (this.channel) this.channel.close();
|
||||
this.log("Closing connection");
|
||||
if (this.connection) this.connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.WebRTCPeer = NostrWebRTCPeer;
|
||||
}
|
131
src/classes/webrtc/webrtc-relay-client.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import NostrWebRTCPeer from "./nostr-webrtc-peer";
|
||||
import { AbstractRelay, AbstractRelayConstructorOptions } from "nostr-tools/abstract-relay";
|
||||
|
||||
export class WebRtcWebSocket extends EventTarget implements WebSocket {
|
||||
binaryType: BinaryType = "blob";
|
||||
bufferedAmount: number = 0;
|
||||
extensions: string = "";
|
||||
protocol: string = "webrtc";
|
||||
|
||||
peer: NostrWebRTCPeer;
|
||||
url: string;
|
||||
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null;
|
||||
onerror: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null;
|
||||
onopen: ((this: WebSocket, ev: Event) => any) | null = null;
|
||||
|
||||
constructor(peer: NostrWebRTCPeer) {
|
||||
super();
|
||||
this.peer = peer;
|
||||
this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey;
|
||||
|
||||
this.peer.on("message", this.handleMessage, this);
|
||||
this.peer.on("connected", this.handleConnect, this);
|
||||
this.peer.on("disconnected", this.handleDisconnect, this);
|
||||
|
||||
if (this.readyState === WebRtcWebSocket.OPEN) {
|
||||
setTimeout(() => this.handleConnect(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
get readyState() {
|
||||
const state = this.peer.connection?.connectionState;
|
||||
switch (state) {
|
||||
case "closed":
|
||||
case "disconnected":
|
||||
return this.CLOSED;
|
||||
case "failed":
|
||||
return this.CLOSED;
|
||||
case "connected":
|
||||
return this.OPEN;
|
||||
case "new":
|
||||
case "connecting":
|
||||
default:
|
||||
return this.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string) {
|
||||
const event = new MessageEvent("message", { data });
|
||||
this.onmessage?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
private handleConnect() {
|
||||
const event = new Event("open");
|
||||
this.onopen?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
private handleDisconnect() {
|
||||
const event = new CloseEvent("close", { reason: "none" });
|
||||
this.onclose?.(event);
|
||||
this.dispatchEvent(event);
|
||||
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("connected", this.handleConnect, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
send(data: unknown): void {
|
||||
if (typeof data === "string") {
|
||||
this.peer.send(data);
|
||||
} else throw new Error("Unsupported data type");
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
close(code?: unknown, reason?: unknown): void {
|
||||
this.peer.hangup();
|
||||
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("connected", this.handleConnect, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
readonly CONNECTING = WebSocket.CONNECTING;
|
||||
readonly OPEN = WebSocket.OPEN;
|
||||
readonly CLOSING = WebSocket.CLOSING;
|
||||
readonly CLOSED = WebSocket.CLOSED;
|
||||
static readonly CONNECTING = WebSocket.CONNECTING;
|
||||
static readonly OPEN = WebSocket.OPEN;
|
||||
static readonly CLOSING = WebSocket.CLOSING;
|
||||
static readonly CLOSED = WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
export default class WebRtcRelayClient extends AbstractRelay {
|
||||
stats = {
|
||||
events: {
|
||||
published: 0,
|
||||
received: 0,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(peer: NostrWebRTCPeer, opts: AbstractRelayConstructorOptions) {
|
||||
super("wss://example.com", opts);
|
||||
|
||||
// @ts-expect-error
|
||||
this.url = `webrtc+nostr:` + peer.answerEvent?.pubkey;
|
||||
|
||||
this.connectionTimeout = 30_000;
|
||||
|
||||
// @ts-expect-error
|
||||
this._WebSocket = function () {
|
||||
return new WebRtcWebSocket(peer);
|
||||
};
|
||||
}
|
||||
|
||||
publish(event: NostrEvent): Promise<string> {
|
||||
this.stats.events.published++;
|
||||
return super.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.WebRtcWebSocket = WebRtcWebSocket;
|
||||
// @ts-expect-error
|
||||
window.WebRtcRelayClient = WebRtcRelayClient;
|
||||
}
|
121
src/classes/webrtc/webrtc-relay-server.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { Filter, NostrEvent } from "nostr-tools";
|
||||
import { AbstractRelay, Subscription } from "nostr-tools/abstract-relay";
|
||||
|
||||
import NostrWebRTCPeer from "./nostr-webrtc-peer";
|
||||
import { logger } from "../../helpers/debug";
|
||||
|
||||
type EventMap = {
|
||||
call: [NostrEvent];
|
||||
};
|
||||
|
||||
export default class WebRtcRelayServer extends EventEmitter<EventMap> {
|
||||
log = logger.extend("WebRtcRelayServer");
|
||||
|
||||
peer: NostrWebRTCPeer;
|
||||
upstream: AbstractRelay;
|
||||
|
||||
// A map of subscriptions
|
||||
subscriptions = new Map<string, Subscription>();
|
||||
|
||||
stats = {
|
||||
events: {
|
||||
sent: 0,
|
||||
received: 0,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(peer: NostrWebRTCPeer, upstream: AbstractRelay) {
|
||||
super();
|
||||
this.peer = peer;
|
||||
this.upstream = upstream;
|
||||
|
||||
this.peer.on("message", this.handleMessage, this);
|
||||
this.peer.on("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
|
||||
private send(data: any[]) {
|
||||
this.peer.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async handleMessage(message: string) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
|
||||
if (!Array.isArray(data)) throw new Error("Message is not an array");
|
||||
|
||||
// Pass the data to appropriate handler
|
||||
switch (data[0]) {
|
||||
case "REQ":
|
||||
await this.handleSubscriptionMessage(data);
|
||||
break;
|
||||
case "EVENT":
|
||||
// only handle publish EVENT methods
|
||||
if (typeof data[1] !== "string") {
|
||||
await this.handleEventMessage(data);
|
||||
}
|
||||
break;
|
||||
case "CLOSE":
|
||||
await this.handleCloseMessage(data);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.log("Failed to handle message", message, err);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
handleSubscriptionMessage(data: any[]) {
|
||||
const [_, id, ...filters] = data as [string, string, ...Filter[]];
|
||||
|
||||
let sub = this.subscriptions.get(id);
|
||||
if (sub) {
|
||||
sub.filters = filters;
|
||||
sub.fire();
|
||||
} else {
|
||||
sub = this.upstream.subscribe(filters, {
|
||||
onevent: (event) => {
|
||||
this.stats.events.sent++;
|
||||
this.send(["EVENT", id, event]);
|
||||
},
|
||||
onclose: (reason) => this.send(["CLOSED", id, reason]),
|
||||
oneose: () => this.send(["EOSE", id]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseMessage(data: any[]) {
|
||||
const [_, id] = data as [string, string, ...Filter[]];
|
||||
|
||||
let sub = this.subscriptions.get(id);
|
||||
if (sub) {
|
||||
sub.close();
|
||||
this.subscriptions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEventMessage(data: any[]) {
|
||||
const [_, event] = data as [string, NostrEvent];
|
||||
|
||||
try {
|
||||
const result = await this.upstream.publish(event);
|
||||
this.stats.events.received++;
|
||||
this.peer.send(JSON.stringify(["OK", event.id, true, result]));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) this.peer.send(JSON.stringify(["OK", event.id, false, error.message]));
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
for (const [id, sub] of this.subscriptions) sub.close();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.peer.off("message", this.handleMessage, this);
|
||||
this.peer.off("disconnected", this.handleDisconnect, this);
|
||||
}
|
||||
}
|
29
src/components/account-info-badge.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Badge, BadgeProps } from "@chakra-ui/react";
|
||||
import { Account } from "../classes/accounts/account";
|
||||
|
||||
export default function AccountTypeBadge({ account, ...props }: BadgeProps & { account: Account }) {
|
||||
let color = "gray";
|
||||
switch (account.type) {
|
||||
case "extension":
|
||||
color = "green";
|
||||
break;
|
||||
case "serial":
|
||||
color = "teal";
|
||||
break;
|
||||
case "local":
|
||||
color = "orange";
|
||||
break;
|
||||
case "nsec":
|
||||
color = "red";
|
||||
break;
|
||||
case "pubkey":
|
||||
color = "blue";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme={color}>
|
||||
{account.type}
|
||||
</Badge>
|
||||
);
|
||||
}
|
185
src/components/app-handler-modal/index.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Link,
|
||||
LinkBox,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { NostrEvent, kinds, nip19 } from "nostr-tools";
|
||||
import { encodeDecodeResult } from "../../helpers/nip19";
|
||||
import { ExternalLinkIcon } from "../icons";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { Kind0ParsedContent, getDisplayName, parseMetadataContent } from "../../helpers/nostr/user-metadata";
|
||||
import { MetadataAvatar } from "../user/user-avatar";
|
||||
import HoverLinkOverlay from "../hover-link-overlay";
|
||||
import ArrowRight from "../icons/arrow-right";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
|
||||
import { CopyIconButton } from "../copy-icon-button";
|
||||
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
|
||||
function useEventFromDecode(decoded: nip19.DecodeResult) {
|
||||
switch (decoded.type) {
|
||||
case "note":
|
||||
return useSingleEvent(decoded.data);
|
||||
case "nevent":
|
||||
return useSingleEvent(decoded.data.id, decoded.data.relays);
|
||||
case "naddr":
|
||||
return useReplaceableEvent(decoded.data, decoded.data.relays);
|
||||
}
|
||||
}
|
||||
function getKindFromDecoded(decoded: nip19.DecodeResult) {
|
||||
switch (decoded.type) {
|
||||
case "naddr":
|
||||
return decoded.data.kind;
|
||||
case "nevent":
|
||||
return decoded.data.kind;
|
||||
case "note":
|
||||
return kinds.ShortTextNote;
|
||||
case "nprofile":
|
||||
return kinds.Metadata;
|
||||
case "npub":
|
||||
return kinds.Metadata;
|
||||
}
|
||||
}
|
||||
|
||||
function AppHandler({ app, decoded }: { app: NostrEvent; decoded: nip19.DecodeResult }) {
|
||||
const metadata = useMemo(() => parseMetadataContent(app), [app]);
|
||||
const link = useMemo(() => {
|
||||
const tag = app.tags.find((t) => t[0] === "web" && t[2] === decoded.type) || app.tags.find((t) => t[0] === "web");
|
||||
return tag ? tag[1].replace("<bech32>", encodeDecodeResult(decoded)) : undefined;
|
||||
}, [decoded, app]);
|
||||
|
||||
const ref = useEventIntersectionRef(app);
|
||||
|
||||
if (!link) return null;
|
||||
return (
|
||||
<Flex as={LinkBox} gap="2" py="2" px="4" alignItems="center" ref={ref} overflow="hidden" shrink={0}>
|
||||
<MetadataAvatar metadata={metadata} />
|
||||
<Box overflow="hidden">
|
||||
<HoverLinkOverlay fontWeight="bold" href={link} isExternal>
|
||||
{getDisplayName(metadata, app.pubkey)}
|
||||
</HoverLinkOverlay>
|
||||
<Text noOfLines={3}>{metadata.about}</Text>
|
||||
</Box>
|
||||
<ArrowRight boxSize={6} ml="auto" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppHandlerModal({
|
||||
decoded,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: { decoded: nip19.DecodeResult } & Omit<ModalProps, "children">) {
|
||||
const readRelays = useReadRelays();
|
||||
const event = useEventFromDecode(decoded);
|
||||
const kind = event?.kind ?? getKindFromDecoded(decoded);
|
||||
const alt = event?.tags.find((t) => t[0] === "alt")?.[1];
|
||||
const address = encodeDecodeResult(decoded);
|
||||
const timeline = useTimelineLoader(
|
||||
`${kind}-apps`,
|
||||
readRelays,
|
||||
kind ? { kinds: [kinds.Handlerinformation], "#k": [String(kind)] } : { kinds: [kinds.Handlerinformation] },
|
||||
);
|
||||
|
||||
const autofocus = useBreakpointValue({ base: false, lg: true });
|
||||
const [search, setSearch] = useState("");
|
||||
const apps = useSubject(timeline.timeline).filter((a) => a.content.length > 0);
|
||||
|
||||
const filteredApps = apps.filter((app) => {
|
||||
if (search.length > 1) {
|
||||
try {
|
||||
const parsed = JSON.parse(app.content) as Kind0ParsedContent;
|
||||
if (getDisplayName(parsed, app.pubkey).toLowerCase().includes(search.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {}
|
||||
return false;
|
||||
} else return true;
|
||||
});
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">{kind === 0 ? `View profile in` : `View event (k:${kind}) in`}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody display="flex" gap="2" flexDirection="column" p="0">
|
||||
{alt && (
|
||||
<Text fontStyle="italic" px="4">
|
||||
{alt}
|
||||
</Text>
|
||||
)}
|
||||
{apps.length > 4 && (
|
||||
<Box px="4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search apps"
|
||||
autoFocus={autofocus}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Flex gap="2" direction="column" overflowX="hidden" overflowY="auto" maxH="sm">
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{filteredApps.map((app) => (
|
||||
<AppHandler decoded={decoded} app={app} key={app.id} />
|
||||
))}
|
||||
</IntersectionObserverProvider>
|
||||
</Flex>
|
||||
|
||||
<FormControl px="4">
|
||||
<FormLabel>Embed Code</FormLabel>
|
||||
<Flex gap="2" overflow="hidden">
|
||||
<Input readOnly value={"nostr:" + address} size="sm" />
|
||||
<CopyIconButton value={"nostr:" + address} size="sm" aria-label="Copy embed code" />
|
||||
</Flex>
|
||||
</FormControl>
|
||||
<FormControl px="4">
|
||||
<FormLabel>Share URL</FormLabel>
|
||||
<Flex gap="2" overflow="hidden">
|
||||
<Input readOnly value={"https://njump.me/" + address} size="sm" />
|
||||
<CopyIconButton value={"https://njump.me/" + address} size="sm" aria-label="Copy embed code" />
|
||||
</Flex>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter display="flex" gap="2" p="4">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
as={Link}
|
||||
variant="outline"
|
||||
href={`https://nostrapp.link/#${address}?select=true`}
|
||||
isExternal
|
||||
rightIcon={<ExternalLinkIcon />}
|
||||
colorScheme="primary"
|
||||
>
|
||||
nostrapp.link
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
23
src/components/blured-image.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Box, Image, ImageProps, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
export default function BlurredImage(props: ImageProps) {
|
||||
const { isOpen, onOpen } = useDisclosure();
|
||||
return (
|
||||
<Box overflow="hidden">
|
||||
<Image
|
||||
onClick={
|
||||
!isOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cursor="pointer"
|
||||
filter={isOpen ? "" : "blur(1.5rem)"}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
29
src/components/blurhash-image.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { decode } from "blurhash";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type BlurhashImageProps = {
|
||||
blurhash: string;
|
||||
width: number;
|
||||
height: number;
|
||||
} & Omit<BoxProps, "width" | "height" | "children">;
|
||||
|
||||
export default function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.canvas.width = width;
|
||||
ctx.canvas.height = height;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const pixels = decode(blurhash, width, height);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, [blurhash, width, height]);
|
||||
|
||||
return <Box as="canvas" ref={canvasRef} {...props} />;
|
||||
}
|
75
src/components/cashu/inline-cashu-card.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useAsync } from "react-use";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Button, ButtonGroup, Card, CardProps, Heading, IconButton, Link } from "@chakra-ui/react";
|
||||
import { getDecodedToken, Token, CashuMint } from "@cashu/cashu-ts";
|
||||
|
||||
import { CopyIconButton } from "../copy-icon-button";
|
||||
import useUserMetadata from "../../hooks/use-user-metadata";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { ECashIcon, WalletIcon } from "../icons";
|
||||
import { getMint } from "../../services/cashu-mints";
|
||||
|
||||
function RedeemButton({ token }: { token: string }) {
|
||||
const account = useCurrentAccount()!;
|
||||
const metadata = useUserMetadata(account.pubkey);
|
||||
|
||||
const lnurl = metadata?.lud16 ?? "";
|
||||
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
|
||||
lnurl,
|
||||
)}&autopay=yes`;
|
||||
return (
|
||||
<Button as={Link} href={url} isExternal colorScheme="primary">
|
||||
Redeem
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InlineCachuCard({ token, ...props }: Omit<CardProps, "children"> & { token: string }) {
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const [cashu, setCashu] = useState<Token>();
|
||||
const { value: spendable } = useAsync(async () => {
|
||||
if (!cashu) return;
|
||||
for (const token of cashu.token) {
|
||||
const mint = await getMint(token.mint);
|
||||
const spent = await mint.check({ proofs: token.proofs.map((p) => ({ secret: p.secret })) });
|
||||
if (spent.spendable.some((v) => v === false)) return false;
|
||||
}
|
||||
return true;
|
||||
}, [cashu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.startsWith("cashuA") || token.length < 10) return;
|
||||
try {
|
||||
const cashu = getDecodedToken(token);
|
||||
setCashu(cashu);
|
||||
} catch (e) {}
|
||||
}, [token]);
|
||||
|
||||
if (!cashu) return null;
|
||||
|
||||
const amount = cashu?.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
||||
return (
|
||||
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap" {...props}>
|
||||
<ECashIcon boxSize={10} color="green.500" />
|
||||
<Box>
|
||||
<Heading size="md" textDecoration={spendable === false ? "line-through" : undefined}>
|
||||
{amount} Cashu sats{spendable === false ? " (Spent)" : ""}
|
||||
</Heading>
|
||||
{cashu && <small>Mint: {new URL(cashu.token[0].mint).hostname}</small>}
|
||||
</Box>
|
||||
{cashu.memo && <Box>Memo: {cashu.memo}</Box>}
|
||||
<ButtonGroup ml="auto">
|
||||
<CopyIconButton value={token} title="Copy Token" aria-label="Copy Token" />
|
||||
<IconButton
|
||||
as={Link}
|
||||
icon={<WalletIcon />}
|
||||
title="Open Wallet"
|
||||
aria-label="Open Wallet"
|
||||
href={`cashu://` + token}
|
||||
/>
|
||||
{account && <RedeemButton token={token} />}
|
||||
</ButtonGroup>
|
||||
</Card>
|
||||
);
|
||||
}
|
60
src/components/charts/event-kinds-pie-chart.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useMemo } from "react";
|
||||
import { useColorModeValue, useTheme } from "@chakra-ui/react";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
CategoryScale,
|
||||
ChartData,
|
||||
Colors,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import { Pie } from "react-chartjs-2";
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
function createChartData(kinds: Record<string, number>) {
|
||||
const sortedKinds = Object.entries(kinds)
|
||||
.map(([kind, count]) => ({ kind, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const data: ChartData<"pie", number[], string> = {
|
||||
labels: sortedKinds.map(({ kind }) => String(kind)),
|
||||
datasets: [{ label: "# of events", data: sortedKinds.map(({ count }) => count) }],
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function EventKindsPieChart({ kinds }: { kinds: Record<string, number> }) {
|
||||
const theme = useTheme();
|
||||
const token = theme.semanticTokens.colors["chakra-body-text"];
|
||||
const color = useColorModeValue(token._light, token._dark) as string;
|
||||
|
||||
const chartData = useMemo(() => createChartData(kinds), [kinds]);
|
||||
|
||||
return (
|
||||
<Pie
|
||||
data={chartData}
|
||||
options={{
|
||||
color,
|
||||
plugins: { colors: { forceOverride: true } },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
59
src/components/charts/event-kinds-table.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ButtonGroup, IconButton, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
|
||||
import { TrashIcon } from "../icons";
|
||||
|
||||
export default function EventKindsTable({
|
||||
kinds,
|
||||
deleteKind,
|
||||
}: {
|
||||
kinds: Record<string, number>;
|
||||
deleteKind?: (kind: string) => Promise<void>;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState<string>();
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
Object.entries(kinds)
|
||||
.map(([kind, count]) => ({ kind, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
[kinds],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableContainer minH="sm">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th isNumeric>Kind</Th>
|
||||
<Th isNumeric>Count</Th>
|
||||
{deleteKind && <Th w="4"></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{sorted.map(({ kind, count }) => (
|
||||
<Tr key={kind}>
|
||||
<Td isNumeric>{kind}</Td>
|
||||
<Td isNumeric>{count}</Td>
|
||||
{deleteKind && (
|
||||
<Td isNumeric>
|
||||
<ButtonGroup size="xs">
|
||||
<IconButton
|
||||
isLoading={deleting === kind}
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Delete kind"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDeleting(kind);
|
||||
deleteKind(kind).finally(() => setDeleting(undefined));
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
17
src/components/common-event/event-verification-icon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { memo } from "react";
|
||||
import { verifyEvent } from "nostr-tools";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { CheckIcon, VerificationFailed } from "../icons";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
|
||||
function EventVerificationIcon({ event }: { event: NostrEvent }) {
|
||||
const { showSignatureVerification } = useAppSettings();
|
||||
if (!showSignatureVerification) return null;
|
||||
|
||||
if (!verifyEvent(event)) {
|
||||
return <VerificationFailed color="red.500" />;
|
||||
}
|
||||
return <CheckIcon color="green.500" />;
|
||||
}
|
||||
export default memo(EventVerificationIcon);
|
17
src/components/common-menu-items/copy-embed-code.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { CopyToClipboardIcon } from "../icons";
|
||||
import relayHintService from "../../services/event-relay-hint";
|
||||
|
||||
export default function CopyEmbedCodeMenuItem({ event }: { event: NostrEvent }) {
|
||||
const address = relayHintService.getSharableEventAddress(event);
|
||||
|
||||
return (
|
||||
address && (
|
||||
<MenuItem onClick={() => window.navigator.clipboard.writeText("nostr:" + address)} icon={<CopyToClipboardIcon />}>
|
||||
Copy embed code
|
||||
</MenuItem>
|
||||
)
|
||||
);
|
||||
}
|
19
src/components/common-menu-items/delete-event.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { useDeleteEventContext } from "../../providers/route/delete-event-provider";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { TrashIcon } from "../icons";
|
||||
|
||||
export default function DeleteEventMenuItem({ event, label }: { event: NostrEvent; label?: string }) {
|
||||
const account = useCurrentAccount();
|
||||
const { deleteEvent } = useDeleteEventContext();
|
||||
|
||||
return (
|
||||
account?.pubkey === event.pubkey && (
|
||||
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(event)}>
|
||||
{label ?? "Delete Note"}
|
||||
</MenuItem>
|
||||
)
|
||||
);
|
||||
}
|
25
src/components/common-menu-items/mute-user.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { MuteIcon, UnmuteIcon } from "../icons";
|
||||
import { useMuteModalContext } from "../../providers/route/mute-modal-provider";
|
||||
import useUserMuteActions from "../../hooks/use-user-mute-actions";
|
||||
|
||||
export default function MuteUserMenuItem({ event }: { event: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const { isMuted, mute, unmute } = useUserMuteActions(event.pubkey);
|
||||
const { openModal } = useMuteModalContext();
|
||||
|
||||
if (account?.pubkey === event.pubkey) return null;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={isMuted ? unmute : () => openModal(event.pubkey)}
|
||||
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
color="red.500"
|
||||
>
|
||||
{isMuted ? "Unmute User" : "Mute User"}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
20
src/components/common-menu-items/open-in-app.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useCallback, useContext, useMemo } from "react";
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { ExternalLinkIcon } from "../icons";
|
||||
import { AppHandlerContext } from "../../providers/route/app-handler-provider";
|
||||
import relayHintService from "../../services/event-relay-hint";
|
||||
|
||||
export default function OpenInAppMenuItem({ event }: { event: NostrEvent }) {
|
||||
const address = useMemo(() => relayHintService.getSharableEventAddress(event), [event]);
|
||||
const { openAddress } = useContext(AppHandlerContext);
|
||||
const open = useCallback(() => address && openAddress(address), [address, openAddress]);
|
||||
|
||||
if (!address) return null;
|
||||
return (
|
||||
<MenuItem icon={<ExternalLinkIcon />} onClick={open}>
|
||||
View in app...
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
44
src/components/common-menu-items/pin-note.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import useUserPinList from "../../hooks/use-user-pin-list";
|
||||
import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event";
|
||||
import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists";
|
||||
import { PinIcon } from "../icons";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
|
||||
export default function PinNoteMenuItem({ event }: { event: NostrEvent }) {
|
||||
const publish = usePublishEvent();
|
||||
const account = useCurrentAccount();
|
||||
const { list } = useUserPinList(account?.pubkey);
|
||||
|
||||
const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false;
|
||||
const label = isPinned ? "Unpin Note" : "Pin Note";
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const togglePin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
let draft: DraftNostrEvent = {
|
||||
kind: PIN_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: list?.content ?? "",
|
||||
tags: list?.tags ? Array.from(list.tags) : [],
|
||||
};
|
||||
|
||||
if (isPinned) draft = listRemoveEvent(draft, event.id);
|
||||
else draft = listAddEvent(draft, event.id);
|
||||
|
||||
await publish(label, draft);
|
||||
setLoading(false);
|
||||
}, [list, isPinned]);
|
||||
|
||||
if (event.pubkey !== account?.pubkey) return null;
|
||||
|
||||
return (
|
||||
<MenuItem onClick={togglePin} icon={<PinIcon />} isDisabled={loading || !!account?.readonly}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
38
src/components/common-menu-items/quote-event.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useCallback, useContext, useMemo } from "react";
|
||||
import { MenuItem, useToast } from "@chakra-ui/react";
|
||||
import { kinds, nip19 } from "nostr-tools";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { QuoteEventIcon } from "../icons";
|
||||
import useUserMetadata from "../../hooks/use-user-metadata";
|
||||
import { PostModalContext } from "../../providers/route/post-modal-provider";
|
||||
import relayHintService from "../../services/event-relay-hint";
|
||||
import { getParsedZap } from "../../helpers/nostr/zaps";
|
||||
|
||||
export default function QuoteEventMenuItem({ event }: { event: NostrEvent }) {
|
||||
const toast = useToast();
|
||||
const address = useMemo(() => relayHintService.getSharableEventAddress(event), [event]);
|
||||
const metadata = useUserMetadata(event.pubkey);
|
||||
const { openModal } = useContext(PostModalContext);
|
||||
|
||||
const share = useCallback(async () => {
|
||||
let content = "";
|
||||
|
||||
// if its a zap, mention the original author
|
||||
if (event.kind === kinds.Zap) {
|
||||
const parsed = getParsedZap(event);
|
||||
if (parsed) content += "nostr:" + nip19.npubEncode(parsed.event.pubkey) + "\n";
|
||||
}
|
||||
|
||||
content += "\nnostr:" + address;
|
||||
openModal({ cacheFormKey: null, initContent: content });
|
||||
}, [metadata, event, toast, address]);
|
||||
|
||||
return (
|
||||
address && (
|
||||
<MenuItem onClick={share} icon={<QuoteEventIcon />}>
|
||||
Quote Event
|
||||
</MenuItem>
|
||||
)
|
||||
);
|
||||
}
|
44
src/components/common-menu-items/share-link.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useCallback } from "react";
|
||||
import { MenuItem, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { ShareIcon } from "../icons";
|
||||
import useUserMetadata from "../../hooks/use-user-metadata";
|
||||
import { getDisplayName } from "../../helpers/nostr/user-metadata";
|
||||
import useShareableEventAddress from "../../hooks/use-shareable-event-address";
|
||||
|
||||
export default function ShareLinkMenuItem({ event }: { event: NostrEvent }) {
|
||||
const toast = useToast();
|
||||
const address = useShareableEventAddress(event);
|
||||
const metadata = useUserMetadata(event.pubkey);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
const data: ShareData = {
|
||||
url: "https://njump.me/" + address,
|
||||
title: event.tags.find((t) => t[0] === "title")?.[1] || "Nostr note by " + getDisplayName(metadata, event.pubkey),
|
||||
};
|
||||
|
||||
if (event.content.length <= 256) data.text = event.content;
|
||||
|
||||
try {
|
||||
if (navigator.canShare?.(data)) {
|
||||
await navigator.share(data);
|
||||
} else {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(data.url!);
|
||||
toast({ status: "success", description: "Copied" });
|
||||
} else toast({ description: data.url, isClosable: true, duration: null });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) toast({ status: "error", description: err.message });
|
||||
}
|
||||
}, [metadata, event, toast]);
|
||||
|
||||
return (
|
||||
address && (
|
||||
<MenuItem onClick={handleClick} icon={<ShareIcon />}>
|
||||
Share Link
|
||||
</MenuItem>
|
||||
)
|
||||
);
|
||||
}
|
57
src/components/compact-note-content.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { Box, BoxProps, Text } from "@chakra-ui/react";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../helpers/embeds";
|
||||
import {
|
||||
embedNostrLinks,
|
||||
embedNostrMentions,
|
||||
embedNostrHashtags,
|
||||
embedEmoji,
|
||||
renderGenericUrl,
|
||||
} from "./external-embeds";
|
||||
import { LightboxProvider } from "./lightbox-provider";
|
||||
|
||||
function buildContents(event: NostrEvent | DraftNostrEvent, textOnly = false) {
|
||||
let content: EmbedableContent = [event.content.trim().replace(/\n+/g, "\n")];
|
||||
|
||||
// common
|
||||
content = embedUrls(content, [renderGenericUrl]);
|
||||
|
||||
// nostr
|
||||
content = embedNostrLinks(content, textOnly);
|
||||
content = embedNostrMentions(content, event);
|
||||
content = embedNostrHashtags(content, event);
|
||||
content = embedEmoji(content, event);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export type NoteContentsProps = {
|
||||
event: NostrEvent | DraftNostrEvent;
|
||||
textOnly?: boolean;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export const CompactNoteContent = React.memo(
|
||||
({ event, maxLength, textOnly = false, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
||||
let content = buildContents(event, textOnly);
|
||||
let truncated = maxLength !== undefined ? truncateEmbedableContent(content, maxLength) : content;
|
||||
|
||||
return (
|
||||
<LightboxProvider>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{truncated}
|
||||
{truncated !== content ? (
|
||||
<>
|
||||
<span>...</span>
|
||||
<Text as="span" fontWeight="bold" ml="4">
|
||||
Show More
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</LightboxProvider>
|
||||
);
|
||||
},
|
||||
);
|
76
src/components/compact-user-stack.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Flex,
|
||||
FlexProps,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tag,
|
||||
TagProps,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import UserAvatar from "./user/user-avatar";
|
||||
import { getDisplayName } from "../helpers/nostr/user-metadata";
|
||||
import useUserMetadata from "../hooks/use-user-metadata";
|
||||
|
||||
function UserTag({ pubkey, ...props }: { pubkey: string } & Omit<TagProps, "children">) {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
|
||||
return (
|
||||
<Tag as={RouterLink} to={`/u/${npub}`} {...props}>
|
||||
<UserAvatar pubkey={pubkey} size="xs" mr="2" title={displayName} />
|
||||
{displayName}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserAvatarStack({
|
||||
pubkeys,
|
||||
maxUsers,
|
||||
label = "Users",
|
||||
...props
|
||||
}: { pubkeys: string[]; maxUsers?: number; label?: string } & FlexProps) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const clamped = maxUsers ? pubkeys.slice(0, maxUsers) : pubkeys;
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && <span>{label}</span>}
|
||||
<Flex alignItems="center" gap="-4" overflow="hidden" cursor="pointer" onClick={onOpen} {...props}>
|
||||
{clamped.map((pubkey) => (
|
||||
<UserAvatar key={pubkey} pubkey={pubkey} size="2xs" />
|
||||
))}
|
||||
{clamped.length !== pubkeys.length && (
|
||||
<Text mx="1" fontSize="sm" lineHeight={0}>
|
||||
+{pubkeys.length - clamped.length}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader px="4" pt="4" pb="2">
|
||||
{label}:
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px="4" pb="4" pt="0">
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{pubkeys.map((pubkey) => (
|
||||
<UserTag key={pubkey} pubkey={pubkey} p="2" fontWeight="bold" fontSize="md" />
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
30
src/components/copy-icon-button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { CheckIcon, CopyToClipboardIcon } from "./icons";
|
||||
|
||||
type CopyIconButtonProps = Omit<IconButtonProps, "icon" | "value"> & {
|
||||
value: string | undefined | (() => string);
|
||||
icon?: IconButtonProps["icon"];
|
||||
};
|
||||
|
||||
export const CopyIconButton = ({ value, icon, ...props }: CopyIconButtonProps) => {
|
||||
const toast = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={copied ? <CheckIcon boxSize="1.5em" /> : icon || <CopyToClipboardIcon boxSize="1.2em" />}
|
||||
onClick={() => {
|
||||
const v: string | undefined = typeof value === "function" ? value() : value;
|
||||
|
||||
if (v && navigator.clipboard && !copied) {
|
||||
navigator.clipboard.writeText(v);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else toast({ description: v, isClosable: true, duration: null });
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
14
src/components/debug-modal/debug-event-button.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { IconButton, IconButtonProps } from "@chakra-ui/react";
|
||||
import { CodeIcon } from "../icons";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { useContext } from "react";
|
||||
import { DebugModalContext } from "../../providers/route/debug-modal-provider";
|
||||
|
||||
export default function DebugEventButton({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "aria-label">) {
|
||||
const { open } = useContext(DebugModalContext);
|
||||
|
||||
return <IconButton icon={<CodeIcon />} aria-label="Raw Event" onClick={() => open(event)} {...props} />;
|
||||
}
|
19
src/components/debug-modal/debug-event-menu-item.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useContext } from "react";
|
||||
import { MenuItem, MenuItemProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import { CodeIcon } from "../icons";
|
||||
import { DebugModalContext } from "../../providers/route/debug-modal-provider";
|
||||
|
||||
export default function DebugEventMenuItem({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<MenuItemProps, "icon" | "aria-label">) {
|
||||
const { open } = useContext(DebugModalContext);
|
||||
|
||||
return (
|
||||
<MenuItem onClick={() => open(event)} icon={<CodeIcon />} {...props}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
137
src/components/debug-modal/event-debug-modal.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { PropsWithChildren, ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Heading,
|
||||
AccordionItem,
|
||||
Accordion,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
AccordionButton,
|
||||
Box,
|
||||
ModalHeader,
|
||||
Code,
|
||||
AccordionPanelProps,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { ModalProps } from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { getContentPointers, getContentTagRefs, getThreadReferences } from "../../helpers/nostr/event";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import RawValue from "./raw-value";
|
||||
import { CopyIconButton } from "../copy-icon-button";
|
||||
import DebugEventTags from "./event-tags";
|
||||
import relayHintService from "../../services/event-relay-hint";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
actions,
|
||||
...props
|
||||
}: PropsWithChildren<{ label: string; actions?: ReactNode }> & Omit<AccordionPanelProps, "children">) {
|
||||
return (
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
{label}
|
||||
</Box>
|
||||
{actions && <div onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
<AccordionIcon ml="2" />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel display="flex" flexDirection="column" gap="2" alignItems="flex-start" {...props}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonCode({ data }: { data: any }) {
|
||||
return (
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
|
||||
const contentRefs = useMemo(() => getContentPointers(event.content), [event]);
|
||||
const publish = usePublishEvent();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const broadcast = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await publish("Broadcast", event);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal size="6xl" {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">{event.id}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p="0">
|
||||
<Accordion allowToggle defaultIndex={event.content ? 1 : 2}>
|
||||
<Section label="IDs">
|
||||
<RawValue heading="Event Id" value={event.id} />
|
||||
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
|
||||
<RawValue heading="NIP-19 Pointer" value={relayHintService.getSharableEventAddress(event)} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
label="Content"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" value={event.content} size="sm" />}
|
||||
>
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{event.content}
|
||||
</Code>
|
||||
|
||||
{contentRefs.length > 0 && (
|
||||
<>
|
||||
<Heading size="md" px="2">
|
||||
embeds
|
||||
</Heading>
|
||||
{contentRefs.map((pointer, i) => (
|
||||
<>
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{pointer.type + "\n"}
|
||||
{JSON.stringify(pointer.data, null, 2)}
|
||||
</Code>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
<Section
|
||||
label="JSON"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" value={JSON.stringify(event)} size="sm" />}
|
||||
>
|
||||
<JsonCode data={event} />
|
||||
</Section>
|
||||
<Section label="Threading" p="0">
|
||||
<JsonCode data={getThreadReferences(event)} />
|
||||
</Section>
|
||||
<Section label="Tags">
|
||||
<DebugEventTags event={event} />
|
||||
<Heading size="sm">Tags referenced in content</Heading>
|
||||
<JsonCode data={getContentTagRefs(event.content, event.tags)} />
|
||||
</Section>
|
||||
<Section label="Relays">
|
||||
<Button onClick={broadcast} mr="auto" colorScheme="primary" isLoading={loading}>
|
||||
Broadcast
|
||||
</Button>
|
||||
</Section>
|
||||
</Accordion>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
96
src/components/debug-modal/event-tags.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { Box, Button, Flex, Link, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { NostrEvent, nip19 } from "nostr-tools";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { Tag, isATag, isETag, isPTag } from "../../types/nostr-event";
|
||||
import { aTagToAddressPointer, eTagToEventPointer } from "../../helpers/nostr/event";
|
||||
import { EmbedEventPointer } from "../embed-event";
|
||||
import UserAvatarLink from "../user/user-avatar-link";
|
||||
import UserLink from "../user/user-link";
|
||||
import UserDnsIdentity from "../user/user-dns-identity";
|
||||
|
||||
function EventTag({ tag }: { tag: Tag }) {
|
||||
const expand = useDisclosure();
|
||||
const content = `[${tag[0]}] ${tag.slice(1).join(", ")}`;
|
||||
const props = {
|
||||
fontWeight: "bold",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1.2em",
|
||||
isTruncated: true,
|
||||
color: "GrayText",
|
||||
};
|
||||
|
||||
const toggle = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
expand.onToggle();
|
||||
},
|
||||
[expand.onToggle],
|
||||
);
|
||||
|
||||
if (isETag(tag)) {
|
||||
const pointer = eTagToEventPointer(tag);
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.neventEncode(pointer)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
|
||||
</>
|
||||
);
|
||||
} else if (isATag(tag)) {
|
||||
const pointer = aTagToAddressPointer(tag);
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.naddrEncode(pointer)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && <EmbedEventPointer pointer={{ type: "naddr", data: pointer }} />}
|
||||
</>
|
||||
);
|
||||
} else if (isPTag(tag)) {
|
||||
const pubkey = tag[1];
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.npubEncode(pubkey)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && (
|
||||
<Flex gap="4" p="2">
|
||||
<UserAvatarLink pubkey={pubkey} />
|
||||
<Box>
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
<br />
|
||||
<UserDnsIdentity pubkey={pubkey} />
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<Text title={content} {...props}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DebugEventTags({ event }: { event: NostrEvent }) {
|
||||
const expand = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="link" color="GrayText" fontFamily="monospace" onClick={expand.onToggle} isTruncated>
|
||||
[{expand.isOpen ? "-" : "+"}] Tags ({event.tags.length})
|
||||
</Button>
|
||||
{expand.isOpen && (
|
||||
<Flex direction="column" gap="1" px="2" my="2">
|
||||
{event.tags.map((tag, i) => (
|
||||
<EventTag key={i} tag={tag} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|