first commit

This commit is contained in:
Your Name 2024-09-17 18:11:08 -06:00
parent 9daf1bf86b
commit 307c15a1da
3188 changed files with 96544 additions and 0 deletions

8
=2.0.0 Normal file
View 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
View 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

1
CNAME Normal file
View File

@ -0,0 +1 @@
nostrudel.ninja

28
CONTRIBUTING.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
identifier: noStrudel
maintainers:
- npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr
relays:
- wss://nostrue.com/
- wss://nostr.wine/
- wss://nos.lol/

135
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

220
public/logo.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

BIN
screenshots/community2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

BIN
screenshots/drawer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
screenshots/emojis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

251
screenshots/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
screenshots/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
screenshots/streaming.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
screenshots/streaming2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
screenshots/streams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

63
scripts/build-icons.mjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View File

@ -0,0 +1,9 @@
import { Account } from "./account";
export default class PubkeyAccount extends Account {
readonly type = "pubkey";
constructor(pubkey: string) {
super(pubkey);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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
View 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++;
}
}
}

View 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);
}
}

View 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),
);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}
}

View 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;
}
}

View 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 = [];
}
}

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
export enum RelayMode {
NONE = 0,
READ = 1,
WRITE = 2,
ALL = 1 | 2,
}

View 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`;
}
}

View 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;
}
}

View 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));
}
}

View 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,
];
}

View 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
View 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
View 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;
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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 } },
}}
/>
);
}

View 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>
);
}

View 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);

View 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>
)
);
}

View 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>
)
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
);
}

View 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>
)
);
}

View 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>
);
},
);

View 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>
</>
);
}

View 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}
/>
);
};

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More