Compare commits

...

163 Commits

Author SHA1 Message Date
0043b7e8bd
feat: full event verify in wasm 2024-01-03 23:20:37 +00:00
267c09a946 dont save p tags for now 2024-01-04 00:03:17 +02:00
2fa75e8e3d fuzzysearch username -> display_name 2024-01-03 23:57:51 +02:00
2a2144b59b increase idb throttle time, query by pubkey+kind index 2024-01-03 23:56:17 +02:00
7ac5bb6d41 LRUSet for seenEvents 2024-01-03 23:33:35 +02:00
38093fdf3b load all profiles from indexeddb 2024-01-03 21:29:17 +02:00
98e1be883b trending posts into system, check sigs 2024-01-03 20:22:58 +02:00
e700c97c71 chore: Update translations 2024-01-03 16:49:44 +00:00
4d9226b3b6
feat: wallet connection flow 2024-01-03 16:46:34 +00:00
0cc0a47501 fix: rm eventMatchesFilter 2024-01-03 18:34:28 +02:00
d187fbc6e5 save only followDistance <= 2 evts to idb 2024-01-03 17:39:55 +02:00
6582b4c7d5 chore: Update translations 2024-01-03 15:34:43 +00:00
acbd3a9004 package.json 2024-01-03 17:24:15 +02:00
1199418d0e check filter match on the worker side 2024-01-03 17:19:22 +02:00
395848fd8c add optional idb "relay" worker 2024-01-03 16:53:33 +02:00
8571ea0aa7 chore: Update translations 2024-01-03 10:34:35 +00:00
d840f2b952 NostrSystemEvent "event" id -> subId 2024-01-03 12:27:05 +02:00
43591c4ce6 chore: Update translations 2024-01-02 18:20:53 +00:00
bc7ec4d77f
feat: new wallet design 2024-01-02 18:11:44 +00:00
0d10122394
fix: event hook 2024-01-02 16:06:43 +00:00
8bbbd11f7a
fix: nwc history 2024-01-02 16:05:21 +00:00
7c8136b503
fix: check sig exists 2024-01-02 15:51:19 +00:00
6ad99e7e95 ProxyImg fix 2024-01-02 10:24:34 +02:00
f7d8d1de16 chore: Update translations 2024-01-02 07:38:33 +00:00
2c14d64a95 NoteInner prop waitUntilInView = false 2024-01-02 09:33:22 +02:00
81c9285d46 proxyimg fix 2024-01-02 01:30:10 +02:00
6928ad04d7 chore: Update translations 2024-01-01 22:59:18 +00:00
19eeb890ac broken img placeholder fix 2024-01-02 00:54:25 +02:00
3d98532e40 chore: Update translations 2024-01-01 22:32:53 +00:00
4bbad0563b useMemo in Avatar 2024-01-02 00:25:44 +02:00
13fc3bb843 notetime init value 2024-01-02 00:21:30 +02:00
7b72f9f775
chore: formatting 2023-12-28 17:40:53 +00:00
c2e1215667
refactor: extract connection pool
wip: setup system-worker
2023-12-28 17:40:26 +00:00
e7e7fdc14d chore: Update translations 2023-12-27 20:42:52 +00:00
3e52bb755e show trending error only if no cached content 2023-12-27 22:35:51 +02:00
789476c677 NostrLink initialState 2023-12-27 15:07:20 +02:00
f47994b3ee faster nostrlinkhandler 2023-12-27 14:45:00 +02:00
9d2b867552 sw caching for nostr.json and images 2023-12-26 21:33:22 +02:00
d82c7957be if suggested follows is empty, show trending users 2023-12-26 20:50:34 +02:00
3af04a79cc rm dataProcessor from useCachedFetch hook deps 2023-12-26 20:40:57 +02:00
9fc0b676f5
feat: add dimensions from void.cat uploads 2023-12-26 18:35:54 +00:00
f70d752fae useCachedFetch hook for trending api calls 2023-12-26 20:15:26 +02:00
6b88df96ab rm webrtc client for now 2023-12-26 19:20:12 +02:00
1f03a5ee5a NostrBand cache fix 2023-12-26 19:20:11 +02:00
9c94e84b9d chore: Update translations 2023-12-26 08:17:40 +00:00
8c1bbe58f6 cache NostrBand requests in localstorage 2023-12-26 10:07:11 +02:00
118ada989e chore: Update translations 2023-12-23 07:33:28 +00:00
1b9dc3f480 add missing login 2023-12-23 09:26:52 +02:00
82bb71136e chore: Update translations 2023-12-22 08:14:56 +00:00
74591a6adb add telegram link to donate page 2023-12-22 10:11:29 +02:00
87d3bbe1a1 move chat files to same dir, webrtc, chat 2023-12-21 23:40:29 +02:00
1639937d8c webrtc works in dev, add some missing list keys 2023-12-21 22:40:08 +02:00
e10a11b707 chore: Update translations 2023-12-21 20:04:03 +00:00
9e6971423e webrtc 2023-12-21 21:57:08 +02:00
782a2217b4 chore: Update translations 2023-12-21 17:59:56 +00:00
1309937869 wip webrtc 2023-12-21 19:51:37 +02:00
0c2ed147b0
fix: match file extensions with lower case 2023-12-20 16:07:04 +00:00
9ed5757875 chore: Update translations 2023-12-20 14:15:35 +00:00
06b7dcad11
feat: tools pages
Various other fixes:
- Better handeling of limit/since/before merging
- Expose timeout through request builder
- Expose PickN through request builder
- Fix tests
2023-12-20 14:08:05 +00:00
96368d4a2b fix build 2023-12-20 13:41:24 +02:00
bf822aae5b chore: Update translations 2023-12-20 10:34:53 +00:00
80690df15a fix spotlight modal for replies 2023-12-20 12:31:24 +02:00
df66a861f7 make grid media modal closable when not loaded 2023-12-20 12:06:52 +02:00
f2d46e340a chore: Update translations 2023-12-19 14:22:08 +00:00
47f70b0157 preload next & prev grid image modal 2023-12-19 16:19:59 +02:00
c1c99f1b9e full width modal imgs 2023-12-19 15:41:25 +02:00
7eb8edbf74 modal full width vid 2023-12-19 12:47:47 +02:00
34e892937e use ProxyImg in grid 2023-12-19 11:15:38 +02:00
30df180e33 chore: Update translations 2023-12-19 08:54:33 +00:00
083f512bdf go to next grid item with arrows 2023-12-19 10:50:37 +02:00
0f4352aa1b chore: Update translations 2023-12-18 17:00:48 +00:00
7040253f32
feat: apply for leader 2023-12-18 16:58:30 +00:00
a937c75c64 chore: Update translations 2023-12-18 16:03:54 +00:00
7523b41610
feat: community leaders 2023-12-18 16:00:22 +00:00
457cba32a7 odell column title uppercase 2023-12-18 17:11:26 +02:00
be3f46c7f1 disable notification graph in iris 2023-12-18 17:05:04 +02:00
509c1664c1 chore: Update translations 2023-12-18 15:03:10 +00:00
a34bf6591c link to settings in sm and edit in md 2023-12-18 17:01:16 +02:00
eef4e526c1 fix edit profile link 2023-12-18 16:54:42 +02:00
9bd5062922 chore: Update translations 2023-12-18 14:47:07 +00:00
3021001c02 chore: Update translations 2023-12-18 14:40:37 +00:00
33963f35ed translate settings divisions 2023-12-18 16:37:35 +02:00
9702529437 new settings layout 2023-12-18 16:29:06 +02:00
63b3ad2d57 note content rendering fix 2023-12-18 13:26:50 +02:00
e2e3c9e638 react without spinner 2023-12-18 13:17:06 +02:00
8aeda3f7a1 rm CONFIG.checkSigs (default always true) 2023-12-18 10:04:32 +02:00
722a3a1a0e hide note creator toast on Iris 2023-12-18 09:59:17 +02:00
ffda31895a chore: Update translations 2023-12-18 07:58:16 +00:00
ad449bc295 CONFIG.signUp.defaultFollows 2023-12-18 09:55:06 +02:00
73026ff152 chore: Update translations 2023-12-18 07:27:47 +00:00
04756d2741 push notifications 2023-12-18 09:24:22 +02:00
bedcd7aba6 chore: Update translations 2023-12-15 13:29:33 +00:00
933e891b37 notifications status in settings 2023-12-15 15:23:24 +02:00
1389bfe1ed tailwind close btn that works on light theme 2023-12-15 12:04:04 +02:00
39549dbe96 hide side nav "messages" in readonly 2023-12-15 10:28:44 +02:00
57c0998eaa fix unknown tag error from notes 2023-12-15 10:17:23 +02:00
a4570084ef chore: Update translations 2023-12-15 07:23:19 +00:00
20a0a3aea4 ErrorBoundary for notes, wip notif settings 2023-12-15 09:19:10 +02:00
2bf62f3a03
fix: unused imports 2023-12-14 14:25:01 +00:00
188f96c86f chore: Update translations 2023-12-14 14:24:37 +00:00
a7ab7b024f
fix: typo 2023-12-14 14:23:30 +00:00
1ba9218a2f
chore: prepare release 2023-12-14 14:04:17 +00:00
86ed4e042e
feat: edit profile button 2023-12-14 14:04:17 +00:00
4212fe8dc9 chore: Update translations 2023-12-14 12:37:42 +00:00
817b791e13
refactor: cleanup
closes https://github.com/v0l/snort/pull/575
2023-12-14 12:36:24 +00:00
8dc12b31df
Squashed commit of the following:
commit 52ca4c48661f69e0885fda9bfca7b3171b9e6a36
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Thu Nov 30 16:41:45 2023 +0000

    compose the api call in debounce and useeffect

commit fc6a933643ad7f4ac26851eccec080f81e5a84d9
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Thu Nov 30 03:32:21 2023 +0000

    useeffect for lud16 verification, reuse nip05verifier and some cleanup

commit 0516b38a2f074e1d5457e26f484305410cfe102c
Merge: 202eaa07 aaa56738
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Tue Nov 21 06:04:01 2023 +0000

    Merge branch 'main' into enhancements/593-validation

commit 202eaa0773b19ae782381ac8e21c4a8200c57b26
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Tue Nov 21 05:49:15 2023 +0000

    Lud16 test and some clean up

commit 169596288d77e6eaa1998b5b8ec2b6944e240ae4
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Mon Nov 20 16:15:52 2023 +0000

    username and about length validation

commit d150a0622cfc90650d2342587c2d5d513085fe01
Author: Kamal Raj Sekar <notify.kamalraj@gmail.com>
Date:   Mon Nov 20 15:38:45 2023 +0000

    verify nostr address - nip05
2023-12-14 12:25:13 +00:00
a086dc101f
chore: formatting
closes https://github.com/v0l/snort/pull/576
2023-12-14 12:19:01 +00:00
0d1e73d40f
refer border color variable 2023-12-14 12:19:01 +00:00
7a27bb022e
show border when there is no selected DM 2023-12-14 12:18:59 +00:00
91ae31a267 chore: Update translations 2023-12-14 12:16:05 +00:00
444b7b5379
new close buttons
closes https://github.com/v0l/snort/pull/577
2023-12-14 12:15:06 +00:00
27f6597f88 chore: Update translations 2023-12-14 12:06:29 +00:00
9e65024652
feat: preferLargeMedia config 2023-12-14 12:05:26 +00:00
6e5fba4f15
feat: image integrity check 2023-12-14 11:51:56 +00:00
b38527ca1f subscribe to notifications from nav sidebar notifs click 2023-12-14 13:01:19 +02:00
14692aceb9 chore: Update translations 2023-12-14 09:46:11 +00:00
e107d4cb7e show profileCard in some components 2023-12-14 11:43:28 +02:00
26a88537a5 chore: Update translations 2023-12-13 15:15:45 +00:00
e3642c5449 postsOnly=true in followed-by-friends feed 2023-12-13 17:13:39 +02:00
ac94f7c1e4 chore: Update translations 2023-12-13 14:58:05 +00:00
85846422dd followed-by-friends feed 2023-12-13 16:55:06 +02:00
1b70815109 disable iris http cache 2023-12-13 15:40:24 +02:00
8ab1b9a643 center images on mobile 2023-12-13 14:32:38 +02:00
7cb51aa8cf rm rounded-sm, not compatible with object-contain 2023-12-13 14:30:22 +02:00
4e2189a893 chore: Update translations 2023-12-13 12:12:22 +00:00
87ad31df30 stretch small images to fit the container 2023-12-13 14:10:54 +02:00
f124082f6f video & img styles 2023-12-13 13:27:42 +02:00
e408389cdb separate components for different media types 2023-12-13 12:12:10 +02:00
1fe6a2a50d pause / play vid with useInView 2023-12-13 11:37:14 +02:00
b199d297ce useInView rootMargin 2023-12-13 11:25:23 +02:00
3fa13de33e chore: Update translations 2023-12-13 08:51:56 +00:00
20e40c1f65 long form note: fix text overflow, truncate in feed 2023-12-13 10:49:08 +02:00
4ed6ec7c3d
feat: use eventemitter3 in ExternalStore 2023-12-12 22:47:06 +00:00
8d6cdb3868
feat: schnorr check in wasm 2023-12-12 22:46:36 +00:00
9431b294c6 img width fix 2023-12-12 21:27:12 +02:00
33888988af constant img & video container size when no imeta present 2023-12-12 16:19:42 +02:00
15806c56d0
feat: remove wallet button 2023-12-12 13:55:34 +00:00
f0af0c81f0 min-height for quote & spinner 2023-12-12 15:44:43 +02:00
d9309b5fac chore: Update translations 2023-12-12 13:24:29 +00:00
b41e8a919a
feat: upgrade wallet support 2023-12-12 13:22:26 +00:00
6951383045 hide profilecard by default, calculate new notes pos with js 2023-12-12 14:51:07 +02:00
ad57b440a9 chore: Update translations 2023-12-12 12:32:01 +00:00
e0a6df7f3a latest notes mobile fix 2023-12-12 14:30:20 +02:00
47d187516f chore: Update translations 2023-12-12 12:08:25 +00:00
dcb3389aa1 fix notecreator layout 2023-12-12 14:04:38 +02:00
fce7cc70a3
feat: parse imeta 2023-12-11 11:36:23 +00:00
cb95032e7c chore: Update translations 2023-12-11 10:40:08 +00:00
b63d46e96d
chore: formatting 2023-12-11 10:37:36 +00:00
07e42405a0
chore: tweaks 2023-12-11 10:37:03 +00:00
0f6fe23f18
chore: formatting 2023-12-10 18:40:24 +00:00
755ba17dab
chore: track events with props 2023-12-10 18:40:01 +00:00
d00f8b0d85
chore: formatting 2023-12-10 17:41:34 +00:00
a1e9df8254
feat: track events in note creator 2023-12-10 17:41:33 +00:00
d2cec4909c
feat: track zappool payout activity 2023-12-10 17:41:33 +00:00
91709c88be chore: Update translations 2023-12-10 17:29:18 +00:00
5add66711a
fix: return hash from nostr.build 2023-12-10 17:23:57 +00:00
d167579348
feat: add imeta hash 2023-12-10 17:19:09 +00:00
735d5fd5a5 chore: Update translations 2023-12-08 16:32:50 +00:00
4d6331ce81 CONFIG.defaultPreferences.checkSigs 2023-12-08 18:30:52 +02:00
bee8498283 style 2023-12-08 13:30:55 +02:00
74bc8bafda update yarn.lock 2023-12-08 11:35:07 +02:00
4f7152d3e0 layout, full width gallery on mobile 2023-12-08 11:24:28 +02:00
31d9c52080 styles 2023-12-08 09:48:39 +02:00
193 changed files with 6241 additions and 2209 deletions

View File

@ -1,3 +1,51 @@
# v0.1.24
`+11,573,-3,010`
## Added
- 3 Column layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Fuzzy cache search - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Followed by on profile pages - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show more on long notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Better error message page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Media grid feed - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Mobile fixed footer - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Follow button on profile search results - nostr:npub17q5n2z8naw0xl6vu9lvt560lg33pdpe29k0k09umlfxm3vc4tqrq466f2y
- Invite codes (WIP Community Program) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- `imeta` tag insertion for images - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Wallet settings page improvements - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Nostr Wallet Connect upgrade (balance + history) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Schnorr sig check in WASM binary - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Autoplay videos in feed (muted) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Followed by friends feed (a feed of your 2nd degree follows posts) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- imgproxy image integrity check (sha256 from `imeta` passed to imgproxy) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Changed
- Removed Twitter embed - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Removed attachment button on DM's - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Note broadcaster dialog changed to toast notification - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Removed npub link from profile (use QR button) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Render image size from `imeta` tags - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Style fixes - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
- Zap pool slider tweak - nostr:npub1ltx67888tz7lqnxlrg06x234vjnq349tcfyp52r0lstclp548mcqnuz40t
- New Malay translations - nostr:npub1cjtt3nywuflj65ftld4v7zzpg0qh3ergycjcym0956vf9eftv7esekxpmn
- Updated Persian translations - nostr:npub1cpazafytvafazxkjn43zjfwtfzatfz508r54f6z6a3rf2ws8223qc3xxpk
- Updated Finnish translations - nostr:npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj
- Updated French translations - nostr:npub1x8dzy9xegwmdk2vy30l8u08caspcqq2yzncxehdsa6kvnte9pr3qnt8pg4 & nostr:npub13w02l37gkjwv90lnklfet5653jj0p5ueu976v3dpda5afvxgw3uslcqdnv
- Updated German translations - nostr:npub19a6x8frkkn2660fw0flz74a7qg8c2jxk5v9p2rsh7tv5e6ftsq3sav63vp
- Updated Hungarian translations - nostr:npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp
- Updated Swedish translations - nostr:npub19jk45jz45gczwfm22y9z69xhaex3nwg47dz84zw096xl6z62amkqj99rv7
- Updated Japanese translations - nostr:npub1wh69w45awqnlsxw7jt5tkymets87h6t4phplkx6ug2ht2qkssswswntjk0
## Fixed
- Longform note overlfow-x - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Trim zap content - nostr:npub1u8lnhlw5usp3t9vmpz60ejpyt649z33hu82wc2hpv6m5xdqmuxhs46turz
---
# v0.1.23
## Added

View File

@ -10,16 +10,25 @@
"publicDir": "public/snort",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0.5,
"bypassImgProxyError": false,
"defaultZapPoolFee": 1,
"features": {
"analytics": true,
"subscriptions": true,
"deck": true,
"zapPool": true
"zapPool": true,
"notificationGraph": true,
"communityLeaders": true
},
"signUp": {
"moderation": true
"moderation": true,
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": {
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": true,
"hideFromNavbar": ["/graph"],
@ -30,5 +39,6 @@
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false }
}
},
"useIndexedDBEvents": false
}

View File

@ -8,19 +8,26 @@
"appleTouchIconUrl": "/img/apple-touch-icon.png",
"navLogo": "/img/icon128.png",
"publicDir": "public/iris",
"httpCache": "https://api.iris.to",
"httpCache": "",
"animalNamePlaceholders": true,
"defaultZapPoolFee": 0.5,
"bypassImgProxyError": true,
"features": {
"analytics": true,
"subscriptions": false,
"deck": true,
"zapPool": true
"zapPool": true,
"notificationGraph": false,
"communityLeaders": false
},
"signUp": {
"moderation": false
"moderation": false,
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
},
"media": {
"bypassImgProxyError": true,
"preferLargeMedia": true
},
"noteCreatorToast": false,
"hideFromNavbar": [],
"eventLinkPrefix": "note",
"profileLinkPrefix": "npub",
@ -30,5 +37,6 @@
"wss://eden.nostr.land/": { "read": true, "write": false },
"wss://relay.nostr.band/": { "read": true, "write": true },
"wss://relay.damus.io/": { "read": true, "write": true }
}
},
"useIndexedDBEvents": true
}

View File

@ -52,15 +52,27 @@ declare const CONFIG: {
httpCache: string;
animalNamePlaceholders: boolean;
defaultZapPoolFee: number;
bypassImgProxyError: boolean;
features: {
analytics: boolean;
subscriptions: boolean;
deck: boolean;
zapPool: boolean;
notificationGraph: boolean;
communityLeaders: boolean;
};
defaultPreferences: {
checkSigs: boolean;
};
signUp: {
moderation: boolean;
defaultFollows: Array<string>;
};
media: {
bypassImgProxyError: boolean;
preferLargeMedia: boolean;
};
communityLeaders?: {
list: string;
};
// Filter urls from nav sidebar
hideFromNavbar: Array<string>;
@ -68,10 +80,11 @@ declare const CONFIG: {
deckSubKind?: number;
showDeck?: boolean;
// Create toast notifications when publishing notes
noteCreatorToast?: boolean;
noteCreatorToast: boolean;
eventLinkPrefix: NostrPrefix;
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
useIndexedDBEvents: boolean;
};
/**

View File

@ -1,6 +1,6 @@
{
"name": "@snort/app",
"version": "0.1.23",
"version": "0.1.24",
"dependencies": {
"@cashu/cashu-ts": "^0.6.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
@ -16,8 +16,9 @@
"@snort/system-web": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.10",
"@void-cat/api": "^1.0.12",
"classnames": "^2.3.2",
"comlink": "^4.4.1",
"debug": "^4.3.4",
"dexie": "^3.2.4",
"emojilib": "^3.0.10",
@ -43,6 +44,7 @@
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
"workbox-core": "^6.4.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^6.4.2",
"workbox-strategies": "^6.4.2"
@ -91,7 +93,7 @@
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^1.0.10",
"@webbtc/webln-types": "^2.1.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"autoprefixer": "^10.4.16",
"config": "^3.3.9",

View File

@ -26,7 +26,7 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
loaded: unixNowMs(),
});
if (update !== "no_change") {
socialGraphInstance.handleFollowEvent(e);
socialGraphInstance.handleEvent(e);
}
}),
);
@ -42,6 +42,6 @@ export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
override async preload() {
await super.preload();
this.snapshot().forEach(e => socialGraphInstance.handleFollowEvent(e));
this.snapshot().forEach(e => socialGraphInstance.handleEvent(e));
}
}

View File

@ -27,8 +27,10 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const authors = session.follows.item;
authors.push(session.publicKey);
const authors = [...session.follows.item];
if (session.publicKey) {
authors.push(session.publicKey);
}
const since = this.newest();
rb.withFilter()
.kinds(this.#kinds)
@ -69,8 +71,10 @@ export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
if (this.#oldest && before <= this.#oldest) {
const rb = new RequestBuilder(`${this.name}-loadmore`);
const authors = session.follows.item;
authors.push(session.publicKey);
const authors = [...session.follows.item];
if (session.publicKey) {
authors.push(session.publicKey);
}
rb.withFilter()
.kinds(this.#kinds)
.authors(authors)

View File

@ -0,0 +1,222 @@
import Dexie, { Table } from "dexie";
import { TaggedNostrEvent, ReqFilter as Filter } from "@snort/system";
import * as Comlink from "comlink";
import LRUSet from "@/Cache/LRUSet";
type Tag = {
id: string;
eventId: string;
type: string;
value: string;
};
type SaveQueueEntry = { event: TaggedNostrEvent; tags: Tag[] };
class IndexedDB extends Dexie {
events!: Table<TaggedNostrEvent>;
tags!: Table<Tag>;
private saveQueue: SaveQueueEntry[] = [];
private seenEvents = new LRUSet<string>(1000);
private subscribedEventIds = new Set<string>();
private subscribedAuthors = new Set<string>();
private subscribedTags = new Set<string>();
private subscribedAuthorsAndKinds = new Set<string>();
constructor() {
super("EventDB");
this.version(5).stores({
events: "id, pubkey, kind, created_at, [pubkey+kind]",
tags: "id, eventId, [type+value]",
});
this.startInterval();
}
private startInterval() {
const processQueue = async () => {
if (this.saveQueue.length > 0) {
try {
const eventsToSave: TaggedNostrEvent[] = [];
const tagsToSave: Tag[] = [];
for (const item of this.saveQueue) {
eventsToSave.push(item.event);
tagsToSave.push(...item.tags);
}
await this.events.bulkPut(eventsToSave);
await this.tags.bulkPut(tagsToSave);
} catch (e) {
console.error(e);
} finally {
this.saveQueue = [];
}
}
setTimeout(() => processQueue(), 3000);
};
setTimeout(() => processQueue(), 3000);
}
handleEvent(event: TaggedNostrEvent) {
if (this.seenEvents.has(event.id)) {
return;
}
this.seenEvents.add(event.id);
// maybe we don't want event.kind 3 tags
const tags =
event.kind === 3
? []
: event.tags
?.filter(tag => {
if (tag[0] === "d") {
return true;
}
if (tag[0] === "e") {
return true;
}
// we're only interested in p tags where we are mentioned
/*
if (tag[0] === "p") {
Key.isMine(tag[1])) { // TODO
return true;
}*/
return false;
})
.map(tag => ({
id: event.id.slice(0, 16) + "-" + tag[0].slice(0, 16) + "-" + tag[1].slice(0, 16),
eventId: event.id,
type: tag[0],
value: tag[1],
})) || [];
this.saveQueue.push({ event, tags });
}
_throttle(func, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
inThrottle = true;
setTimeout(() => {
inThrottle = false;
func.apply(this, args);
}, limit);
}
};
}
subscribeToAuthors = this._throttle(async function (callback: (event: TaggedNostrEvent) => void, limit?: number) {
const authors = [...this.subscribedAuthors];
this.subscribedAuthors.clear();
await this.events
.where("pubkey")
.anyOf(authors)
.limit(limit || 1000)
.each(callback);
}, 200);
subscribeToEventIds = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
const ids = [...this.subscribedEventIds];
this.subscribedEventIds.clear();
await this.events.where("id").anyOf(ids).each(callback);
}, 200);
subscribeToTags = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
const tagPairs = [...this.subscribedTags].map(tag => tag.split("|"));
this.subscribedTags.clear();
await this.tags
.where("[type+value]")
.anyOf(tagPairs)
.each(tag => this.subscribedEventIds.add(tag.eventId));
await this.subscribeToEventIds(callback);
}, 200);
subscribeToAuthorsAndKinds = this._throttle(async function (callback: (event: TaggedNostrEvent) => void) {
const authorsAndKinds = [...this.subscribedAuthorsAndKinds];
this.subscribedAuthorsAndKinds.clear();
// parse pair[1] as int
const pairs = authorsAndKinds.map(pair => {
const [author, kind] = pair.split("|");
return [author, parseInt(kind)];
});
await this.events.where("[pubkey+kind]").anyOf(pairs).each(callback);
}, 200);
async find(filter: Filter, callback: (event: TaggedNostrEvent) => void): Promise<void> {
if (!filter) return;
// make sure only 1 argument is passed
const cb = e => {
this.seenEvents.add(e.id);
callback(e);
};
if (filter["#p"] && Array.isArray(filter["#p"])) {
for (const eventId of filter["#p"]) {
this.subscribedTags.add("p|" + eventId);
}
await this.subscribeToTags(cb);
return;
}
if (filter["#e"] && Array.isArray(filter["#e"])) {
for (const eventId of filter["#e"]) {
this.subscribedTags.add("e|" + eventId);
}
await this.subscribeToTags(cb);
return;
}
if (filter["#d"] && Array.isArray(filter["#d"])) {
for (const eventId of filter["#d"]) {
this.subscribedTags.add("d|" + eventId);
}
await this.subscribeToTags(cb);
return;
}
if (filter.ids?.length) {
filter.ids.forEach(id => this.subscribedEventIds.add(id));
await this.subscribeToEventIds(cb);
return;
}
if (filter.authors?.length && filter.kinds?.length) {
const permutations = filter.authors.flatMap(author => filter.kinds!.map(kind => author + "|" + kind));
permutations.forEach(permutation => this.subscribedAuthorsAndKinds.add(permutation));
await this.subscribeToAuthorsAndKinds(cb);
return;
}
if (filter.authors?.length) {
filter.authors.forEach(author => this.subscribedAuthors.add(author));
await this.subscribeToAuthors(cb);
return;
}
let query = this.events;
if (filter.kinds) {
query = query.where("kind").anyOf(filter.kinds);
}
if (filter.search) {
const regexp = new RegExp(filter.search, "i");
query = query.filter((event: Event) => event.content?.match(regexp));
}
if (filter.limit) {
query = query.limit(filter.limit);
}
// TODO test that the sort is actually working
await query.each(e => {
cb(e);
});
}
}
const db = new IndexedDB();
Comlink.expose(db);

View File

@ -0,0 +1,23 @@
export default class LRUSet<T> {
private set = new Set<T>();
private limit: number;
constructor(limit: number) {
this.limit = limit;
}
add(item: T) {
if (this.set.size >= this.limit) {
this.set.delete(this.set.values().next().value);
}
this.set.add(item);
}
has(item: T) {
return this.set.has(item);
}
values() {
return this.set.values();
}
}

View File

@ -28,11 +28,6 @@ export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7v
*/
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
/**
* Default bootstrap relays
*/
export const DefaultRelays = new Map(Object.entries(CONFIG.defaultRelays));
/**
* Default search relays
*/
@ -155,3 +150,13 @@ export const WavlakeRegex =
* Regex to match any base64 string
*/
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;
/*
* Max username length - profile/settings
*/
export const MaxUsernameLength = 100;
/*
* Max about length - profile/settings
*/
export const MaxAboutLength = 1000;

View File

@ -0,0 +1,15 @@
import Icon from "@/Icons/Icon";
import classNames from "classnames";
export default function CloseButton({ onClick, className }: { onClick?: () => void; className?: string }) {
return (
<div
onClick={onClick}
className={classNames(
"self-center circle flex flex-shrink-0 flex-grow-0 items-center justify-center hover:opacity-80 bg-dark p-2 cursor-pointer",
className,
)}>
<Icon name="close" size={12} />
</div>
);
}

View File

@ -0,0 +1,54 @@
export default function AwardIcon({ size }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 62 62" fill="none" className="award">
<defs>
<linearGradient
id="paint0_linear_2660_40043"
x1="31"
y1="3.57143"
x2="31"
y2="58.4286"
gradientUnits="userSpaceOnUse">
<stop stop-color="#5B2CB3" />
<stop offset="1" stop-color="#811EFF" />
</linearGradient>
<linearGradient
id="paint1_linear_2660_40043"
x1="15.5594"
y1="24.305"
x2="46.433"
y2="24.305"
gradientUnits="userSpaceOnUse">
<stop stop-color="#AC88FF" />
<stop offset="1" stop-color="#7234FF" />
</linearGradient>
</defs>
<g id="award-02">
<rect x="1.85713" y="1.85714" width="58.2857" height="58.2857" rx="29.1429" fill="#AC88FF" fill-opacity="0.2" />
<rect
x="1.85713"
y="1.85714"
width="58.2857"
height="58.2857"
rx="29.1429"
stroke="url(#paint0_linear_2660_40043)"
strokeWidth="3.42857"
/>
<path
id="Solid"
d="M23.2006 52.4983L22.5639 50.9066L23.2006 52.4983L30.9963 49.38L38.7919 52.4983C39.8813 52.934 41.116 52.801 42.0876 52.1432C43.0592 51.4854 43.6412 50.3885 43.6412 49.2151V38.1015C46.467 35.038 48.1957 30.9408 48.1957 26.4427C48.1957 16.9437 40.4952 9.24329 30.9963 9.24329C21.4973 9.24329 13.7968 16.9437 13.7968 26.4427C13.7968 30.9408 15.5255 35.038 18.3513 38.1015V49.2151C18.3513 50.3885 18.9333 51.4854 19.9049 52.1432C20.8765 52.801 22.1112 52.934 23.2006 52.4983ZM27.2967 43.2429L25.4234 43.9922V42.7187C26.0332 42.9275 26.6584 43.1029 27.2967 43.2429ZM34.6958 43.2429C35.3341 43.1029 35.9593 42.9275 36.5691 42.7187V43.9922L34.6958 43.2429Z"
fill="url(#paint1_linear_2660_40043)"
stroke="#251250"
strokeWidth="3.42857"
strokeLinecap="round"
/>
<path
id="Ellipse 1595"
d="M24.2557 14.6002C17.7766 18.3409 15.5567 26.6257 19.2974 33.1049L42.7604 19.5585C39.0196 13.0794 30.7348 10.8595 24.2557 14.6002Z"
fill="white"
fill-opacity="0.1"
/>
</g>
</svg>
);
}

View File

@ -0,0 +1,48 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import AwardIcon from "./Award";
import Modal from "../Modal";
import { Link } from "react-router-dom";
import CloseButton from "../Button/CloseButton";
export function LeaderBadge() {
const [showModal, setShowModal] = useState(false);
return (
<>
<div
className="flex gap-1 p-1 pr-2 items-center border border-[#5B2CB3] rounded-full"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowModal(true);
}}>
<AwardIcon size={16} />
<div className="text-xs font-medium text-[#AC88FF]">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
</div>
{showModal && (
<Modal onClose={() => setShowModal(false)} id="leaders">
<div className="flex flex-col gap-4 items-center relative">
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<AwardIcon size={80} />
<div className="text-3xl font-semibold">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
<p className="text-secondary">
<FormattedMessage
defaultMessage="Community leaders are individuals who grow the nostr ecosystem by being active in their local communities and helping onboard new users. Anyone can become a community leader, but few hold the current honorary title."
id="f1OxTe"
/>
</p>
<Link to="/settings/invite">
<button className="primary">
<FormattedMessage defaultMessage="Become a leader" id="M6C/px" />
</button>
</Link>
</div>
</Modal>
)}
</>
);
}

View File

@ -46,7 +46,7 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
if (youtubeId) {
return (
<iframe
className="-mx-4 md:mx-0 w-max"
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}

View File

@ -1,45 +1,111 @@
import { ProxyImg } from "@/Element/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
import React from "react";
import { IMeta } from "@snort/system";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import classNames from "classnames";
import { useInView } from "react-intersection-observer";
interface MediaElementProps {
mime: string;
url: string;
magnet?: string;
sha256?: string;
blurHash?: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
export function MediaElement(props: MediaElementProps) {
interface AudioElementProps {
url: string;
}
interface VideoElementProps {
url: string;
meta?: IMeta;
}
interface ImageElementProps {
url: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
const AudioElement = ({ url }: AudioElementProps) => {
return <audio key={url} src={url} controls />;
};
const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
const imageRef = useRef<HTMLImageElement | null>(null);
const style = useMemo(() => {
const style = {} as CSSProperties;
if (meta?.height && meta.width && imageRef.current) {
const scale = imageRef.current.offsetWidth / meta.width;
style.height = `${Math.min(document.body.clientHeight * 0.8, meta.height * scale)}px`;
}
return style;
}, [imageRef.current, meta]);
return (
<div
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
})}>
<ProxyImg
key={url}
src={url}
sha256={meta?.sha256}
onClick={onMediaClick}
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
"md:max-h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
})}
style={style}
ref={imageRef}
/>
</div>
);
};
const VideoElement = ({ url }: VideoElementProps) => {
const { proxy } = useImgProxy();
const videoRef = useRef<HTMLVideoElement | null>(null);
const { ref: videoContainerRef, inView } = useInView({ threshold: 0.33 });
const isMobile = window.innerWidth < 768;
const autoplay = window.innerWidth >= 768;
useEffect(() => {
if (isMobile || !videoRef.current) {
return;
}
if (inView) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}, [inView]);
return (
<div
ref={videoContainerRef}
className={classNames("flex justify-center items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
})}>
<video
ref={videoRef}
loop={true}
muted={!isMobile}
src={url}
controls
poster={proxy(url)}
className={classNames("max-h-[80vh]", { "md:max-h-[510px]": !CONFIG.media.preferLargeMedia })}
onClick={e => e.stopPropagation()}
/>
</div>
);
};
export function MediaElement(props: MediaElementProps) {
if (props.mime.startsWith("image/")) {
return (
// constant height container avoids layout shift when images load
<div className="-mx-4 md:mx-0 my-3 md:h-80 flex items-center justify-center">
<ProxyImg key={props.url} src={props.url} onClick={props.onMediaClick} className="max-h-[80vh] md:max-h-80" />
</div>
);
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} />;
} else if (props.mime.startsWith("audio/")) {
return <audio key={props.url} src={props.url} controls />;
return <AudioElement url={props.url} />;
} else if (props.mime.startsWith("video/")) {
return (
<div className="-mx-4 md:mx-0 my-3 md:h-80 flex items-center justify-center">
<video
autoPlay={autoplay}
loop={true}
muted={autoplay}
key={props.url}
src={props.url}
controls
poster={proxy(props.url)}
className="max-h-[80vh] md:max-h-80"
/>
</div>
);
return <VideoElement url={props.url} />;
} else {
return (
<a

View File

@ -78,6 +78,7 @@
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.note-creator-icon.pfp .avatar {

View File

@ -6,7 +6,7 @@ import { TagsInput } from "react-tag-input-component";
import Icon from "@/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { appendDedupe, openFile } from "@/SnortUtils";
import { appendDedupe, openFile, trackEvent } from "@/SnortUtils";
import Textarea from "@/Element/Textarea";
import Modal from "@/Element/Modal";
import ProfileImage from "@/Element/User/ProfileImage";
@ -27,6 +27,7 @@ import { sendEventToRelays } from "@/Element/Event/Create/util";
import { TrendingHashTagsLine } from "@/Element/Event/Create/TrendingHashTagsLine";
import { Toastore } from "@/Toaster";
import { OkResponseRow } from "./OkResponseRow";
import CloseButton from "@/Element/Button/CloseButton";
export function NoteCreator() {
const { formatMessage } = useIntl();
@ -158,6 +159,21 @@ export function NoteCreator() {
async function sendNote() {
const ev = await buildNote();
if (ev) {
let props: Record<string, boolean> | undefined = undefined;
if (ev.tags.find(a => a[0] === "content-warning")) {
props ??= {};
props["content-warning"] = true;
}
if (ev.tags.find(a => a[0] === "poll_option")) {
props ??= {};
props["poll"] = true;
}
if (ev.tags.find(a => a[0] === "zap")) {
props ??= {};
props["zap-split"] = true;
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
@ -193,7 +209,7 @@ export function NoteCreator() {
}
}
async function uploadFile(file: File | Blob) {
async function uploadFile(file: File) {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
@ -215,6 +231,9 @@ export function NoteCreator() {
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
if (rx.metadata.hash) {
imeta.push(`x ${rx.metadata.hash}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) {
@ -256,6 +275,7 @@ export function NoteCreator() {
note.update(v => (v.preview = undefined));
} else if (publisher) {
const tmpNote = await buildNote();
trackEvent("PostNotePreview");
note.update(v => (v.preview = tmpNote));
}
}
@ -291,11 +311,7 @@ export function NoteCreator() {
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
{i > 1 && (
<button onClick={() => removePollOption(i)} className="ml5">
<Icon name="close" size={14} />
</button>
)}
{i > 1 && <CloseButton className="ml5" onClick={() => removePollOption(i)} />}
</div>
</div>
))}
@ -392,7 +408,7 @@ export function NoteCreator() {
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
<div className="flex items-center g8">
<div className="flex flex-col f-4 g4">
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
</h4>
@ -407,7 +423,7 @@ export function NoteCreator() {
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address", id: "WvGmZT" })}
/>
</div>
<div className="flex flex-col f-1 g4">
<div className="flex flex-col flex-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
</h4>
@ -479,7 +495,7 @@ export function NoteCreator() {
function noteCreatorFooter() {
return (
<div className="flex items-center justify-between">
<div className="flex justify-between">
<div className="flex items-center g8">
<ProfileImage
pubkey={login.publicKey ?? ""}
@ -487,6 +503,7 @@ export function NoteCreator() {
link=""
showUsername={false}
showFollowDistance={false}
showProfileCard={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon
@ -579,6 +596,7 @@ export function NoteCreator() {
options={{
showFooter: false,
showContextMenu: false,
showProfileCard: false,
showTime: false,
canClick: false,
showMedia: false,
@ -662,7 +680,11 @@ export function NoteCreator() {
if (!note.show) return null;
return (
<Modal id="note-creator" className="note-creator-modal" onClose={reset}>
<Modal
id="note-creator"
bodyClassName="modal-body flex flex-col gap-4"
className="note-creator-modal"
onClose={reset}>
{noteCreatorForm()}
</Modal>
);

View File

@ -32,7 +32,6 @@ export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => vo
return (
<div className="flex items-center g16">
<Icon name={r.ok ? "check" : "x"} className={r.ok ? "success" : "error"} size={24} />
<div className="flex flex-col grow g4">
<b>{getRelayName(r.relay)}</b>
{r.message && <small>{r.message}</small>}

View File

@ -1,23 +1,21 @@
import { useEffect, useState } from "react";
import { useLocale } from "@/IntlProvider";
import NostrBandApi from "@/External/NostrBand";
import { FormattedMessage } from "react-intl";
import useCachedFetch from "@/Hooks/useCachedFetch";
import { ErrorOrOffline } from "@/Element/ErrorOrOffline";
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
const { lang } = useLocale();
const api = new NostrBandApi();
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
async function loadTrendingHashtags() {
const api = new NostrBandApi();
const rsp = await api.trendingHashtags(lang);
setHashtags(rsp.hashtags);
}
const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags);
useEffect(() => {
loadTrendingHashtags().catch(console.error);
}, []);
if (error && !hashtags) return <ErrorOrOffline error={error} className="p" />;
if (isLoading || hashtags.length === 0) return null;
if (!hashtags || hashtags.length === 0) return;
return (
<div className="flex flex-col g4">
<small>
@ -25,7 +23,10 @@ export function TrendingHashTagsLine(props: { onClick: (tag: string) => void })
</small>
<div className="flex g4 flex-wrap">
{hashtags.slice(0, 5).map(a => (
<span className="px-2 py-1 bg-dark rounded-full pointer nowrap" onClick={() => props.onClick(a.hashtag)}>
<span
key={a.hashtag}
className="px-2 py-1 bg-dark rounded-full pointer nowrap"
onClick={() => props.onClick(a.hashtag)}>
#{a.hashtag}
</span>
))}

View File

@ -7,8 +7,8 @@ export async function sendEventToRelays(
customRelays?: Array<string>,
setResults?: (x: Array<OkResponse>) => void,
) {
console.log("sendEventToRelays", ev, customRelays);
if (customRelays) {
system.HandleEvent({ ...ev, relays: [] });
return removeUndefined(
await Promise.all(
customRelays.map(async r => {

View File

@ -1,5 +1,5 @@
import "./LongFormText.css";
import { CSSProperties, useCallback, useRef, useState } from "react";
import React, { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
@ -11,20 +11,25 @@ import useImgProxy from "@/Hooks/useImgProxy";
import ProfilePreview from "@/Element/User/ProfilePreview";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import classNames from "classnames";
interface LongFormTextProps {
ev: TaggedNostrEvent;
isPreview: boolean;
related: ReadonlyArray<TaggedNostrEvent>;
onClick?: () => void;
truncate?: boolean;
}
const TEXT_TRUNCATE_LENGTH = 400;
export function LongFormText(props: LongFormTextProps) {
const title = findTag(props.ev, "title");
const summary = findTag(props.ev, "summary");
const image = findTag(props.ev, "image");
const { proxy } = useImgProxy();
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), props.related);
@ -85,6 +90,25 @@ export function LongFormText(props: LongFormTextProps) {
}
};
const ToggleShowMore = () => (
<a
className="highlight cursor-pointer"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const shouldTruncate = props.truncate && props.ev.content.length > TEXT_TRUNCATE_LENGTH;
const content = shouldTruncate && !showMore ? props.ev.content.slice(0, TEXT_TRUNCATE_LENGTH) : props.ev.content;
function fullText() {
return (
<>
@ -113,7 +137,9 @@ export function LongFormText(props: LongFormTextProps) {
)}
</div>
<hr />
<Markdown content={props.ev.content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && showMore && <ToggleShowMore />}
<Markdown content={content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && !showMore && <ToggleShowMore />}
<hr />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
</>
@ -121,12 +147,7 @@ export function LongFormText(props: LongFormTextProps) {
}
return (
<div
className="long-form-note flex flex-col g16 p pointer"
onClick={e => {
e.stopPropagation();
props.onClick?.();
}}>
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
<ProfilePreview
pubkey={props.ev.pubkey}
actions={

View File

@ -30,7 +30,15 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
message={
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
}>
<MediaElement mime={m} url={u} sha256={x} magnet={magnet} blurHash={blurHash} />
<MediaElement
mime={m}
url={u}
meta={{
sha256: x,
magnet: magnet,
blurHash: blurHash,
}}
/>
</Reveal>
);
} else {

View File

@ -1,10 +1,3 @@
.note {
min-height: 110px;
display: flex;
flex-direction: column;
gap: 16px;
}
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);

View File

@ -10,6 +10,7 @@ import NoteReaction from "@/Element/Event/NoteReaction";
import ProfilePreview from "@/Element/User/ProfilePreview";
import { NoteInner } from "./NoteInner";
import { LongFormText } from "./LongFormText";
import ErrorBoundary from "@/Element/ErrorBoundary";
export interface NoteProps {
data: TaggedNostrEvent;
@ -26,6 +27,7 @@ export interface NoteProps {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
@ -39,41 +41,50 @@ export interface NoteProps {
longFormPreview?: boolean;
truncate?: boolean;
};
waitUntilInView?: boolean;
}
export default function Note(props: NoteProps) {
const { data: ev, className } = props;
if (ev.kind === EventKind.Repost) {
return <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
}
if (ev.kind === EventKind.FileHeader) {
return <NostrFileElement ev={ev} />;
}
if (ev.kind === EventKind.ZapstrTrack) {
return <ZapstrEmbed ev={ev} />;
}
if (ev.kind === EventKind.FollowSet || ev.kind === EventKind.ContactList) {
return <PubkeyList ev={ev} className={className} />;
}
if (ev.kind === EventKind.LiveEvent) {
return <LiveEvent ev={ev} />;
}
if (ev.kind === EventKind.SetMetadata) {
return <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
}
if (ev.kind === (9041 as EventKind)) {
return <ZapGoal ev={ev} />;
}
if (ev.kind === EventKind.LongFormTextNote) {
return (
<LongFormText
ev={ev}
related={props.related}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
/>
);
let content;
switch (ev.kind) {
case EventKind.Repost:
content = <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
break;
case EventKind.FileHeader:
content = <NostrFileElement ev={ev} />;
break;
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;
break;
case EventKind.LiveEvent:
content = <LiveEvent ev={ev} />;
break;
case EventKind.SetMetadata:
content = <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
break;
case 9041: // Assuming 9041 is a valid EventKind
content = <ZapGoal ev={ev} />;
break;
case EventKind.LongFormTextNote:
content = (
<LongFormText
ev={ev}
related={props.related}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
truncate={props.options?.truncate}
/>
);
break;
default:
content = <NoteInner {...props} />;
}
return <NoteInner {...props} />;
return <ErrorBoundary>{content}</ErrorBoundary>;
}

View File

@ -91,7 +91,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
await interactionCache.react();
interactionCache.react();
}
}

View File

@ -31,14 +31,14 @@ import DisplayName from "@/Element/User/DisplayName";
const TEXT_TRUNCATE_LENGTH = 400;
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note card", className);
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className);
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true });
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { reactions, reposts, deletions, zaps } = useEventReactions(NostrLink.fromEvent(ev), related);
const login = useLogin();
const { pinned, bookmarked } = useLogin();
@ -326,7 +326,7 @@ export function NoteInner(props: NoteProps) {
}
function content() {
if (!inView) return undefined;
if (waitUntilInView && !inView) return undefined;
return (
<>
{options.showHeader && (
@ -335,6 +335,8 @@ export function NoteInner(props: NoteProps) {
pubkey={ev.pubkey}
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
@ -371,9 +373,9 @@ export function NoteInner(props: NoteProps) {
{translation()}
{pollOptions()}
{options.showReactionsLink && (
<div className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
<span className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
</div>
</span>
)}
</div>
{options.showFooter && (

View File

@ -6,7 +6,12 @@ import PageSpinner from "@/Element/PageSpinner";
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
if (!ev.data) return <PageSpinner />;
if (!ev.data)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return (
<Note
data={ev.data}

View File

@ -19,7 +19,7 @@ export interface NoteReactionProps {
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
const { isMuted } = useModeration();
const { inView, ref } = useInView({ triggerOnce: true });
const { inView, ref } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const profile = useUserProfile(inView ? ev.pubkey : "");
const refEvent = useMemo(() => {

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
@ -11,13 +11,17 @@ const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string | JSX.Element>();
const { from, fallback } = props;
const [time, setTime] = useState<string | JSX.Element>(calcTime());
const absoluteTime = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from);
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = new Date(from).toISOString();

View File

@ -12,6 +12,7 @@ import Tabs from "@/Element/Tabs";
import Modal from "@/Element/Modal";
import messages from "../messages";
import CloseButton from "@/Element/Button/CloseButton";
interface ReactionsProps {
show: boolean;
@ -73,9 +74,8 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
return show ? (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<div className="close" onClick={onClose}>
<Icon name="close" />
</div>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
@ -88,7 +88,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
<ProfileImage pubkey={ev.pubkey} />
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
@ -102,6 +102,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
@ -120,7 +121,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
<div className="reaction-icon">
<Icon name="repost" size={16} />
</div>
<ProfileImage pubkey={ev.pubkey} />
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
@ -131,7 +132,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
<div className="reaction-icon">
<Icon name="dislike" />
</div>
<ProfileImage pubkey={ev.pubkey} />
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}

View File

@ -5,11 +5,13 @@ import Reveal from "@/Element/Event/Reveal";
import useLogin from "@/Hooks/useLogin";
import { MediaElement } from "@/Element/Embed/MediaElement";
import { Link } from "react-router-dom";
import { IMeta } from "@snort/system";
interface RevealMediaProps {
creator: string;
link: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
meta?: IMeta;
}
export default function RevealMedia(props: RevealMediaProps) {
@ -66,10 +68,22 @@ export default function RevealMedia(props: RevealMediaProps) {
}}
/>
}>
<MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
</Reveal>
);
} else {
return <MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />;
return (
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
);
}
}

View File

@ -23,7 +23,7 @@ const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
export default ShowMore;
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView();
const { ref, inView } = useInView({ rootMargin: "2000px" });
useEffect(() => {
if (inView) {

View File

@ -18,7 +18,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
return valid && sender ? (
<div className="card">
<div className="flex justify-between">
<ProfileImage pubkey={sender} />
<ProfileImage pubkey={sender} showProfileCard={true} />
{receiver !== pubKey && showZapped && <ProfileImage pubkey={unwrap(receiver)} />}
<h3>
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount ?? 0) }} />

View File

@ -0,0 +1,9 @@
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { TaggedNostrEvent } from "@snort/system";
export default function getEventMedia(event: TaggedNostrEvent) {
const parsed = transformTextCached(event.id, event.content, event.tags);
return parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
}

View File

@ -1,18 +1,14 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { MouseEvent } from "react";
import useImgProxy from "@/Hooks/useImgProxy";
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { Link } from "react-router-dom";
import Icon from "@/Icons/Icon";
import getEventMedia from "@/Element/Event/getEventMedia";
import { ProxyImg } from "@/Element/ProxyImg";
const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => {
const { event, onClick } = props;
const { proxy } = useImgProxy();
const parsed = transformTextCached(event.id, event.content, event.tags);
const media = parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
const media = getEventMedia(event);
if (media.length === 0) return null;
@ -29,7 +25,7 @@ const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent
return (
<Link to={`/${noteId}`} className="aspect-square cursor-pointer hover:opacity-80 relative" onClick={myOnClick}>
<img src={proxy(media[0].content, 256)} alt="Note Media" className="w-full h-full object-cover" />
<ProxyImg src={media[0].content} alt="Note Media" className="w-full h-full object-cover" />
<div className="absolute right-2 top-2 flex flex-col gap-2">
{multiple && <Icon name="copy-solid" className="text-white opacity-80 drop-shadow-md" />}
{isVideo && <Icon name="play-square-outline" className="text-white opacity-80 drop-shadow-md" />}

View File

@ -13,7 +13,7 @@ export default function LoadMore({
shouldLoadMore: boolean;
children?: React.ReactNode;
}) {
const { ref, inView } = useInView();
const { ref, inView } = useInView({ rootMargin: "2000px" });
const [tick, setTick] = useState<number>(0);
useEffect(() => {

View File

@ -10,6 +10,7 @@ import { Newest } from "@/Login";
export type RootTab =
| "following"
| "followed-by-friends"
| "conversations"
| "trending-notes"
| "trending-people"
@ -53,13 +54,13 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: New
),
},
{
tab: "trending-people",
path: `${base}/trending/people`,
show: true,
tab: "followed-by-friends",
path: `${base}/followed-by-friends`,
show: Boolean(pubKey),
element: (
<>
<Icon name="user-up" />
<FormattedMessage defaultMessage="Trending People" id="CVWeJ6" />
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
</>
),
},

View File

@ -11,8 +11,6 @@
.latest-notes-fixed {
position: fixed;
top: 50px;
left: 50%;
transform: translateX(-50%);
width: auto;
z-index: 42;
opacity: 0.9;
@ -23,16 +21,6 @@
border: none;
}
@media (max-width: 520px) {
.latest-notes-fixed {
width: 200px;
padding: 6px 12px;
position: fixed;
top: 12px;
left: calc(50% - 110px);
}
}
.latest-notes .pfp:not(:last-of-type) {
margin: 0;
margin-right: -26px;

View File

@ -1,7 +1,7 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo, useState } from "react";
import { TaggedNostrEvent, EventKind } from "@snort/system";
import { TaggedNostrEvent, EventKind, socialGraphInstance } from "@snort/system";
import { dedupeByPubkey, findTag } from "@/SnortUtils";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
@ -16,6 +16,7 @@ export interface TimelineProps {
postsOnly: boolean;
subject: TimelineSubject;
method: "TIME_RANGE" | "LIMIT_UNTIL";
followDistance?: number;
ignoreModeration?: boolean;
window?: number;
now?: number;
@ -44,13 +45,20 @@ const Timeline = (props: TimelineProps) => {
const { muted, isEventMuted } = useModeration();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
const checkFollowDistance = (a: TaggedNostrEvent) => {
if (props.followDistance === undefined) {
return true;
}
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
return followDistance === props.followDistance;
};
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
props.noSort || a.sort((a, b) => b.created_at - a.created_at);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => props.ignoreModeration || !isEventMuted(a));
.filter(a => (props.ignoreModeration || !isEventMuted(a)) && checkFollowDistance(a));
},
[props.postsOnly, muted, props.ignoreModeration],
[props.postsOnly, muted, props.ignoreModeration, props.followDistance],
);
const mainFeed = useMemo(() => {

View File

@ -13,6 +13,7 @@ export interface TimelineFragment {
export interface TimelineFragProps {
frag: TimelineFragment;
related: Array<TaggedNostrEvent>;
index: number;
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void;
noteContext?: (ev: TaggedNostrEvent) => ReactNode;
@ -41,6 +42,7 @@ export function TimelineFragment(props: TimelineFragProps) {
options={{
truncate: true,
}}
waitUntilInView={props.index > 10}
/>
),
)}

View File

@ -2,12 +2,14 @@ import { useInView } from "react-intersection-observer";
import ProfileImage from "@/Element/User/ProfileImage";
import { FormattedMessage } from "react-intl";
import Icon from "@/Icons/Icon";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useState } from "react";
import { TaggedNostrEvent } from "@snort/system";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { TimelineFragment } from "@/Element/Feed/TimelineFragment";
import { DisplayAs } from "@/Element/Feed/DisplayAsSelector";
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
import ImageGridItem from "@/Element/Feed/ImageGridItem";
import ErrorBoundary from "@/Element/ErrorBoundary";
import getEventMedia from "@/Element/Event/getEventMedia";
export interface TimelineRendererProps {
frags: Array<TimelineFragment>;
@ -23,36 +25,88 @@ export interface TimelineRendererProps {
displayAs?: DisplayAs;
}
export function TimelineRenderer(props: TimelineRendererProps) {
const { ref, inView } = useInView();
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
// filter frags[0].events that have media
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
const [modalEventIndex, setModalEventIndex] = useState<number | undefined>(undefined);
const allEvents = useMemo(() => {
return frags.flatMap(frag => frag.events);
}, [frags]);
const mediaEvents = useMemo(() => {
return allEvents.filter(event => getEventMedia(event).length > 0);
}, [allEvents]);
const renderNotes = () => {
return props.frags.map(frag => (
<TimelineFragment
frag={frag}
related={props.related}
noteRenderer={props.noteRenderer}
noteOnClick={props.noteOnClick}
noteContext={props.noteContext}
/>
));
};
const modalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex] : undefined;
const nextModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex + 1] : undefined;
const prevModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex - 1] : undefined;
const renderGrid = () => {
// TODO Hide images from notes with a content warning, unless otherwise configured
return props.frags.map(frag => (
return (
<>
<div className="grid grid-cols-3 gap-px md:gap-1">
{frag.events.map(event => (
<ImageGridItem event={event} onClick={() => setModalThread(NostrLink.fromEvent(event))} />
{mediaEvents.map((event, index) => (
<ImageGridItem key={event.id} event={event} onClick={() => setModalEventIndex(index)} />
))}
</div>
{modalEvent && (
<SpotlightThreadModal
key={modalEvent.id}
event={modalEvent}
onClose={() => setModalEventIndex(undefined)}
onBack={() => setModalEventIndex(undefined)}
onNext={() => setModalEventIndex(Math.min((modalEventIndex ?? 0) + 1, mediaEvents.length - 1))}
onPrev={() => setModalEventIndex(Math.max((modalEventIndex ?? 0) - 1, 0))}
/>
)}
{nextModalEvent && ( // preload next
<SpotlightThreadModal className="hidden" key={`${nextModalEvent.id}-next`} event={nextModalEvent} />
)}
{prevModalEvent && ( // preload previous
<SpotlightThreadModal className="hidden" key={`${prevModalEvent.id}-prev`} event={prevModalEvent} />
)}
</>
);
}
export function TimelineRenderer(props: TimelineRendererProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const latestNotesFixedRef = useRef<HTMLDivElement | null>(null);
const { ref, inView } = useInView();
const updateLatestNotesPosition = () => {
if (containerRef.current && latestNotesFixedRef.current) {
const parentRect = containerRef.current.getBoundingClientRect();
const childWidth = latestNotesFixedRef.current.offsetWidth;
const leftPosition = parentRect.left + (parentRect.width - childWidth) / 2;
latestNotesFixedRef.current.style.left = `${leftPosition}px`;
}
};
useEffect(() => {
updateLatestNotesPosition();
window.addEventListener("resize", updateLatestNotesPosition);
return () => {
window.removeEventListener("resize", updateLatestNotesPosition);
};
}, [inView, props.latest]);
const renderNotes = () => {
return props.frags.map((frag, index) => (
<ErrorBoundary key={frag.events[0]?.id + index}>
<TimelineFragment
frag={frag}
related={props.related}
noteRenderer={props.noteRenderer}
noteOnClick={props.noteOnClick}
noteContext={props.noteContext}
index={index}
/>
</ErrorBoundary>
));
};
return (
<>
<div ref={containerRef}>
{props.latest.length > 0 && (
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
@ -68,6 +122,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
</div>
{!inView && (
<div
ref={latestNotesFixedRef}
className="card latest-notes latest-notes-fixed pointer fade-in"
onClick={() => props.showLatest(true)}>
{props.latest.slice(0, 3).map(p => {
@ -91,14 +146,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
)}
</>
)}
{props.displayAs === "grid" ? renderGrid() : renderNotes()}
{modalThread && (
<SpotlightThreadModal
thread={modalThread}
onClose={() => setModalThread(undefined)}
onBack={() => setModalThread(undefined)}
/>
)}
</>
{props.displayAs === "grid" ? <Grid frags={props.frags} /> : renderNotes()}
</div>
);
}

View File

@ -60,7 +60,9 @@ export default function Modal(props: ModalProps) {
}, []);
return createPortal(
<div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
<div
className={props.className === "hidden" ? props.className : `modal ${props.className || ""}`}
onClick={props.onClose}>
<div
className={props.bodyClassName || "modal-body"}
onClick={e => {

View File

@ -1,55 +1,65 @@
import useImgProxy from "@/Hooks/useImgProxy";
import React, { HTMLProps, ReactNode, useState } from "react";
import React, { HTMLProps, ReactNode, forwardRef, useState, useMemo, useEffect } from "react";
import { FormattedMessage } from "react-intl";
import { getUrlHostname } from "@/SnortUtils";
type ProxyImgProps = HTMLProps<HTMLImageElement> & {
size?: number;
sha256?: string;
className?: string;
promptToLoadDirectly?: boolean;
missingImageElement?: ReactNode;
};
export const ProxyImg = ({ size, className, promptToLoadDirectly, missingImageElement, ...props }: ProxyImgProps) => {
const { proxy } = useImgProxy();
const [loadFailed, setLoadFailed] = useState(false);
const [bypass, setBypass] = useState(CONFIG.bypassImgProxyError);
export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(
({ size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps, ref) => {
const { proxy } = useImgProxy();
const [loadFailed, setLoadFailed] = useState(false);
const [bypass, setBypass] = useState(CONFIG.media.bypassImgProxyError);
const proxiedSrc = useMemo(() => proxy(props.src ?? "", size, sha256), [props.src, size, sha256]);
const [src, setSrc] = useState(proxiedSrc);
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
return (
<div
className="note-invoice error"
onClick={e => {
e.stopPropagation();
setBypass(true);
}}>
<FormattedMessage
defaultMessage="Failed to proxy image from {host}, click here to load directly"
id="65BmHb"
values={{
host: getUrlHostname(props.src),
}}
/>
</div>
);
}
const src = loadFailed && bypass ? props.src : proxy(props.src ?? "", size);
if (!src || (loadFailed && !bypass)) return missingImageElement;
return (
<img
{...props}
src={src}
width={size}
height={size}
className={className}
onError={e => {
if (props.onError) {
props.onError(e);
useEffect(() => {
setLoadFailed(false);
setSrc(proxy(props.src, size, sha256));
}, [props.src, size, sha256]);
if (loadFailed && !bypass && (promptToLoadDirectly ?? true)) {
return (
<div
className="note-invoice error"
onClick={e => {
e.stopPropagation();
setBypass(true);
}}>
<FormattedMessage
defaultMessage="Failed to proxy image from {host}, click here to load directly"
id="65BmHb"
values={{
host: getUrlHostname(props.src),
}}
/>
</div>
);
}
const handleImageError = e => {
if (props.onError) {
props.onError(e);
} else {
console.error("Failed to load image: ", props.src, e);
if (bypass && src === proxiedSrc) {
setSrc(props.src ?? "");
} else {
console.error("Failed to proxy image ", props.src);
setLoadFailed(true);
}
}}
/>
);
};
}
};
if (!src || loadFailed) return missingImageElement ?? <div>Image not available</div>;
return (
<img {...props} ref={ref} src={src} width={size} height={size} className={className} onError={handleImageError} />
);
},
);

View File

@ -8,9 +8,9 @@ import { NostrLink, tryParseNostrLink } from "@snort/system";
import { useLocation, useNavigate } from "react-router-dom";
import { unixNow } from "@snort/shared";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../Feed/TimelineFeed";
import { fuzzySearch, FuzzySearchResult } from "@/index";
import ProfileImage from "@/Element/User/ProfileImage";
import { socialGraphInstance } from "@snort/system";
import fuzzySearch, { FuzzySearchResult } from "@/FuzzySearch";
const MAX_RESULTS = 3;

View File

@ -19,6 +19,7 @@ import AsyncButton from "@/Element/Button/AsyncButton";
import { ZapTarget, ZapTargetResult, Zapper } from "@/Zapper";
import messages from "./messages";
import CloseButton from "@/Element/Button/CloseButton";
enum ZapType {
PublicZap = 1,
@ -182,9 +183,7 @@ export default function SendSats(props: SendSatsProps) {
<div className="p flex flex-col g12">
<div className="flex g12">
<div className="flex items-center grow">{props.title || title()}</div>
<div onClick={onClose}>
<Icon name="close" />
</div>
<CloseButton onClick={onClose} />
</div>
{zapper && !invoice && (
<SendSatsInput

View File

@ -1,13 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Modal from "@/Element/Modal";
import Icon from "@/Icons/Icon";
import { ProxyImg } from "@/Element/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
interface SpotlightMediaProps {
images: Array<string>;
media: Array<string>;
idx: number;
className: string;
onClose: () => void;
onNext?: () => void;
onPrev?: () => void;
}
const videoSuffixes = ["mp4", "webm", "ogg", "mov", "avi", "mkv"];
@ -17,9 +20,25 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
const [idx, setIdx] = useState(props.idx);
const image = useMemo(() => {
return props.images.at(idx % props.images.length);
return props.media.at(idx % props.media.length);
}, [idx, props]);
const dec = useCallback(() => {
if (idx === 0 && props.onPrev) {
props.onPrev();
} else {
setIdx(s => (s - 1 + props.media.length) % props.media.length);
}
}, [idx, props.onPrev, props.media.length]); // Add dependencies
const inc = useCallback(() => {
if (idx === props.media.length - 1 && props.onNext) {
props.onNext();
} else {
setIdx(s => (s + 1) % props.media.length);
}
}, [idx, props.onNext, props.media.length]); // Add dependencies
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@ -40,27 +59,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
function dec() {
setIdx(s => {
if (s - 1 === -1) {
return props.images.length - 1;
} else {
return s - 1;
}
});
}
function inc() {
setIdx(s => {
if (s + 1 === props.images.length) {
return 0;
} else {
return s + 1;
}
});
}
}, [dec, inc]); // Now dec and inc are stable
const isVideo = useMemo(() => {
return image && videoSuffixes.some(suffix => image.endsWith(suffix));
@ -75,11 +74,11 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
autoPlay={true}
loop={true}
controls={true}
className="max-h-screen max-w-full"
className="max-h-screen max-w-full w-full"
/>
);
} else {
return <ProxyImg src={image} className="max-h-screen max-w-full" />;
return <ProxyImg src={image} className="max-h-screen max-w-full w-full object-contain" />;
}
}, [image, isVideo]);
@ -89,6 +88,10 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
}
};
const hasMultiple = props.media.length > 1;
const hasPrev = hasMultiple || props.onPrev;
const hasNext = hasMultiple || props.onNext;
return (
<div className="select-none relative h-screen flex items-center flex-1 justify-center" onClick={onClickBg}>
{mediaEl}
@ -100,27 +103,27 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
</span>
</div>
<div className="absolute flex flex-row items-center gap-4 right-0 top-0 p-4">
{props.images.length > 1 && `${idx + 1}/${props.images.length}`}
{props.media.length > 1 && `${idx + 1}/${props.media.length}`}
</div>
{props.images.length > 1 && (
<>
<span
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
dec();
}}>
<Icon name="arrowFront" size={24} />
</span>
<span
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
inc();
}}>
<Icon name="arrowFront" size={24} />
</span>
</>
{hasPrev && (
<span
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
dec();
}}>
<Icon name="arrowFront" size={24} />
</span>
)}
{hasNext && (
<span
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
inc();
}}>
<Icon name="arrowFront" size={24} />
</span>
)}
</div>
);

View File

@ -1,12 +1,21 @@
import Modal from "@/Element/Modal";
import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
import { ThreadContextWrapper } from "@/Hooks/useThreadContext";
import { Thread } from "@/Element/Event/Thread";
import { useContext } from "react";
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { SpotlightMedia } from "@/Element/Spotlight/SpotlightMedia";
import { NostrLink } from "@snort/system";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import getEventMedia from "@/Element/Event/getEventMedia";
export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () => void; onBack?: () => void }) {
interface SpotlightThreadModalProps {
thread?: NostrLink;
event?: TaggedNostrEvent;
className?: string;
onClose?: () => void;
onBack?: () => void;
onNext?: () => void;
onPrev?: () => void;
}
export function SpotlightThreadModal(props: SpotlightThreadModalProps) {
const onClose = () => props.onClose?.();
const onBack = () => props.onBack?.();
const onClickBg = (e: React.MouseEvent) => {
@ -15,12 +24,23 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
}
};
if (!props.thread && !props.event) {
throw new Error("SpotlightThreadModal requires either thread or event");
}
const link = props.event ? NostrLink.fromEvent(props.event) : props.thread;
return (
<Modal id="thread-overlay" onClose={onClose} bodyClassName={"flex flex-1"}>
<ThreadContextWrapper link={props.thread}>
<Modal className={props.className} onClose={onClose} bodyClassName={"flex flex-1"}>
<ThreadContextWrapper link={link!}>
<div className="flex flex-row h-screen w-screen">
<div className="flex w-full md:w-2/3 items-center justify-center overflow-hidden" onClick={onClickBg}>
<SpotlightFromThread onClose={onClose} />
<SpotlightFromEvent
event={props.event || thread.root}
onClose={onClose}
onNext={props.onNext}
onPrev={props.onPrev}
/>
</div>
<div className="hidden md:flex w-1/3 min-w-[400px] flex-shrink-0 overflow-y-auto bg-bg-color">
<Thread onBack={onBack} disableSpotlight={true} />
@ -31,13 +51,23 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
);
}
function SpotlightFromThread({ onClose }: { onClose: () => void }) {
const thread = useContext(ThreadContext);
const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : [];
const images = parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
if (images.length === 0) return;
return <SpotlightMedia images={images.map(a => a.content)} idx={0} onClose={onClose} />;
interface SpotlightFromEventProps {
event: TaggedNostrEvent;
onClose: () => void;
onNext?: () => void;
onPrev?: () => void;
}
function SpotlightFromEvent({ event, onClose, onNext, onPrev }: SpotlightFromEventProps) {
const media = getEventMedia(event);
return (
<SpotlightMedia
className="w-full"
media={media.map(a => a.content)}
idx={0}
onClose={onClose}
onNext={onNext}
onPrev={onPrev}
/>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { HexKey, NostrPrefix } from "@snort/system";
import { FormattedMessage } from "react-intl";
@ -9,6 +9,8 @@ import SemisolDevApi from "@/External/SemisolDev";
import useLogin from "@/Hooks/useLogin";
import { hexToBech32 } from "@/SnortUtils";
import { ErrorOrOffline } from "./ErrorOrOffline";
import useCachedFetch from "@/Hooks/useCachedFetch";
import TrendingUsers from "@/Element/Trending/TrendingUsers";
enum Provider {
NostrBand = 1,
@ -17,42 +19,45 @@ enum Provider {
export default function SuggestedProfiles() {
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item }));
const [userList, setUserList] = useState<HexKey[]>();
const [provider, setProvider] = useState(Provider.NostrBand);
const [error, setError] = useState<Error>();
async function loadSuggestedProfiles() {
if (!login.publicKey) return;
setUserList(undefined);
setError(undefined);
try {
switch (provider) {
case Provider.NostrBand: {
const api = new NostrBandApi();
const users = await api.sugguestedFollows(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
const keys = users.profiles.map(a => a.pubkey);
setUserList(keys);
break;
}
case Provider.SemisolDev: {
const api = new SemisolDevApi();
const users = await api.sugguestedFollows(login.publicKey, login.follows);
const keys = users.recommendations.sort(a => a[1]).map(a => a[0]);
setUserList(keys);
break;
}
const getUrlAndKey = () => {
if (!login.publicKey) return { url: null, key: null };
switch (provider) {
case Provider.NostrBand: {
const api = new NostrBandApi();
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
return { url, key: `nostr-band-${url}` };
}
} catch (e) {
if (e instanceof Error) {
setError(e);
case Provider.SemisolDev: {
const api = new SemisolDevApi();
const url = api.suggestedFollowsUrl(login.publicKey, login.follows);
return { url, key: `semisol-dev-${url}` };
}
default:
return { url: null, key: null };
}
}
};
useEffect(() => {
loadSuggestedProfiles();
}, [login.publicKey, login.follows, provider]);
const { url, key } = getUrlAndKey();
const {
data: userList,
error,
isLoading,
} = useCachedFetch(url, key, data => {
switch (provider) {
case Provider.NostrBand:
return data.profiles.map(a => a.pubkey);
case Provider.SemisolDev:
return data.recommendations.sort(a => a[1]).map(a => a[0]);
default:
return [];
}
});
if (error) return <ErrorOrOffline error={error} onRetry={() => {}} />;
if (isLoading) return <PageSpinner />;
if (userList.length === 0) return <TrendingUsers title={""} />;
return (
<>
@ -63,9 +68,7 @@ export default function SuggestedProfiles() {
{/*<option value={Provider.SemisolDev}>semisol.dev</option>*/}
</select>
</div>
{error && <ErrorOrOffline error={error} onRetry={loadSuggestedProfiles} />}
{userList && <FollowListBase pubkeys={userList} showAbout={true} />}
{!userList && !error && <PageSpinner />}
<FollowListBase pubkeys={userList as HexKey[]} showAbout={true} />
</>
);
}

View File

@ -39,7 +39,13 @@
list-style: none;
padding: 0;
-webkit-overflow-scrolling: touch;
margin: 10px 0;
}
@media (min-width: 768px) {
.gallery {
border-radius: 0.125rem;
overflow: hidden;
}
}
.gallery-item {
@ -58,6 +64,7 @@
width: 100%;
height: 100%;
display: block;
border-radius: 0;
}
.gallery:not(:first-child),

View File

@ -1,6 +1,6 @@
import "./Text.css";
import { ReactNode, useState } from "react";
import { HexKey, ParsedFragment } from "@snort/system";
import { HexKey, ParsedFragment, parseIMeta } from "@snort/system";
import classNames from "classnames";
import Invoice from "@/Element/Embed/Invoice";
@ -100,6 +100,7 @@ export default function Text({
const elements = useTextTransformer(id, content, tags);
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
const iMeta = parseIMeta(tags);
function renderContentWithHighlightedText(content: string, textToHighlight: string) {
const textToHighlightArray = textToHighlight.trim().toLowerCase().split(" ");
@ -119,12 +120,12 @@ export default function Text({
return (
<>
{fragments.map(f => {
{fragments.map((f, index) => {
if (typeof f === "string") {
return f;
}
return <HighlightedText content={f.content} />;
return <HighlightedText key={index} content={f.content} />;
})}
</>
);
@ -136,22 +137,26 @@ export default function Text({
</a>
);
const RevealMediaInstance = ({ content }: { content: string }) => (
<RevealMedia
key={content}
link={content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
const RevealMediaInstance = ({ content }: { content: string }) => {
const imeta = iMeta?.[content];
return (
<RevealMedia
key={content}
link={content}
creator={creator}
meta={imeta}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
};
const renderContent = () => {
let lenCtr = 0;
@ -210,7 +215,7 @@ export default function Text({
};
});
const gallery = (
<div className="gallery">
<div className="-mx-4 md:mx-0 my-2 gallery">
{imagesWithGridConfig.map(img => (
<div
key={img.content}
@ -278,7 +283,7 @@ export default function Text({
return (
<div dir="auto" className={classNames("text", className)} onClick={onClick}>
{renderContent()}
{showSpotlight && <SpotlightMediaModal images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
{showSpotlight && <SpotlightMediaModal media={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);
}

View File

@ -10,7 +10,7 @@ export default function ShortNote({ event }: { event: TaggedNostrEvent }) {
return (
<Link to={`/${NostrLink.fromEvent(event).encode(CONFIG.eventLinkPrefix)}`} className="flex flex-col">
<div className="flex flex-row justify-between">
<ProfileImage pubkey={event.pubkey} size={32} />
<ProfileImage pubkey={event.pubkey} size={32} showProfileCard={true} />
<NoteTime from={event.created_at * 1000} />
</div>
<div className="ml-10">

View File

@ -1,12 +1,12 @@
import { ReactNode, useEffect, useState } from "react";
import PageSpinner from "@/Element/PageSpinner";
import { ReactNode } from "react";
import NostrBandApi from "@/External/NostrBand";
import { ErrorOrOffline } from "../ErrorOrOffline";
import { HashTagHeader } from "@/Pages/HashTagsPage";
import { useLocale } from "@/IntlProvider";
import classNames from "classnames";
import { Link } from "react-router-dom";
import useCachedFetch from "@/Hooks/useCachedFetch";
import PageSpinner from "@/Element/PageSpinner";
export default function TrendingHashtags({
title,
@ -17,38 +17,28 @@ export default function TrendingHashtags({
count?: number;
short?: boolean;
}) {
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
const [error, setError] = useState<Error>();
const { lang } = useLocale();
const api = new NostrBandApi();
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
async function loadTrendingHashtags() {
const api = new NostrBandApi();
const rsp = await api.trendingHashtags(lang);
setHashtags(rsp.hashtags.slice(0, count)); // Limit the number of hashtags to the count
}
const {
data: hashtags,
error,
isLoading,
} = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags.slice(0, count));
useEffect(() => {
loadTrendingHashtags().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, []);
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingHashtags} className="p" />;
if (!hashtags) return <PageSpinner />;
if (error && !hashtags) return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
if (isLoading) return <PageSpinner />;
return (
<>
{title}
{hashtags.map(a => {
if (short) {
// return just the hashtag (not HashTagHeader) and post count
return (
<div className="my-1 font-bold" key={a.hashtag}>
<Link to={`/t/${a.hashtag}`} key={a.hashtag}>
#{a.hashtag}
</Link>
<Link to={`/t/${a.hashtag}`}>#{a.hashtag}</Link>
</div>
);
} else {

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useState } from "react";
import { EventExt, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useReactions } from "@snort/system-react";
import PageSpinner from "@/Element/PageSpinner";
@ -14,66 +14,79 @@ import { DisplayAs, DisplayAsSelector } from "@/Element/Feed/DisplayAsSelector";
import ImageGridItem from "@/Element/Feed/ImageGridItem";
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
import useLogin from "@/Hooks/useLogin";
import useCachedFetch from "@/Hooks/useCachedFetch";
import { System } from "@/index";
export default function TrendingNotes({ count = Infinity, small = false }) {
const api = new NostrBandApi();
const { lang } = useLocale();
const trendingNotesUrl = api.trendingNotesUrl(lang);
const storageKey = `nostr-band-${trendingNotesUrl}`;
const {
data: trendingNotesData,
isLoading,
error,
} = useCachedFetch(trendingNotesUrl, storageKey, data => {
return data.notes.map(a => {
const ev = a.event;
const id = EventExt.createId(ev);
if (!System.QueryOptimizer.schnorrVerify(id, ev.sig, ev.pubkey)) {
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
return;
}
System.HandleEvent(ev);
return ev;
});
});
const login = useLogin();
const displayAsInitial = small ? "list" : login.feedDisplayAs ?? "list";
// Added count prop with a default value
const [posts, setPosts] = useState<Array<NostrEvent>>();
const [error, setError] = useState<Error>();
const { lang } = useLocale();
const { isEventMuted } = useModeration();
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true);
const { isEventMuted } = useModeration();
const related = useReactions("trending", trendingNotesData?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true);
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
async function loadTrendingNotes() {
const api = new NostrBandApi();
const trending = await api.trendingNotes(lang);
setPosts(trending.notes.map(a => a.event));
}
if (error && !trendingNotesData) return <ErrorOrOffline error={error} className="p" />;
if (isLoading) return <PageSpinner />;
useEffect(() => {
loadTrendingNotes().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, []);
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingNotes} className="p" />;
if (!posts) return <PageSpinner />;
// if small, render less stuff
const options = {
showFooter: !small,
showReactionsLink: !small,
showMedia: !small,
longFormPreview: !small,
truncate: small,
showContextMenu: !small,
};
const filteredAndLimitedPosts = () => {
return posts.filter(a => !isEventMuted(a)).slice(0, count);
};
const filteredAndLimitedPosts = trendingNotesData
? trendingNotesData.filter(a => !isEventMuted(a)).slice(0, count)
: [];
const renderGrid = () => {
return (
<div className="grid grid-cols-3 gap-px md:gap-1">
{filteredAndLimitedPosts().map(e => (
<ImageGridItem event={e as TaggedNostrEvent} onClick={() => setModalThread(NostrLink.fromEvent(e))} />
{filteredAndLimitedPosts.map(e => (
<ImageGridItem
key={e.id}
event={e as TaggedNostrEvent}
onClick={() => setModalThread(NostrLink.fromEvent(e))}
/>
))}
</div>
);
};
const renderList = () => {
return filteredAndLimitedPosts().map(e =>
return filteredAndLimitedPosts.map(e =>
small ? (
<ShortNote event={e as TaggedNostrEvent} />
<ShortNote key={e.id} event={e as TaggedNostrEvent} />
) : (
<Note data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} options={options} />
<Note
key={e.id}
data={e as TaggedNostrEvent}
related={related?.data ?? []}
depth={0}
options={{
showFooter: !small,
showReactionsLink: !small,
showMedia: !small,
longFormPreview: !small,
truncate: small,
showContextMenu: !small,
}}
/>
),
);
};

View File

@ -1,32 +1,29 @@
import { ReactNode, useEffect, useState } from "react";
import { ReactNode } from "react";
import { HexKey } from "@snort/system";
import FollowListBase from "@/Element/User/FollowListBase";
import PageSpinner from "@/Element/PageSpinner";
import NostrBandApi from "@/External/NostrBand";
import { ErrorOrOffline } from "../ErrorOrOffline";
import useCachedFetch from "@/Hooks/useCachedFetch";
export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) {
const [userList, setUserList] = useState<HexKey[]>();
const [error, setError] = useState<Error>();
const api = new NostrBandApi();
const trendingProfilesUrl = api.trendingProfilesUrl();
const storageKey = `nostr-band-${trendingProfilesUrl}`;
async function loadTrendingUsers() {
const api = new NostrBandApi();
const users = await api.trendingProfiles();
const keys = users.profiles.map(a => a.pubkey).slice(0, count); // Limit the user list to the count
setUserList(keys);
const {
data: trendingUsersData,
isLoading,
error,
} = useCachedFetch(trendingProfilesUrl, storageKey, data => data.profiles.map(a => a.pubkey));
if (error && !trendingUsersData) {
return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
}
useEffect(() => {
loadTrendingUsers().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, []);
if (isLoading) {
return <PageSpinner />;
}
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingUsers} className="p" />;
if (!userList) return <PageSpinner />;
return <FollowListBase pubkeys={userList} showAbout={true} title={title} />;
return <FollowListBase pubkeys={trendingUsersData.slice(0, count) as HexKey[]} showAbout={true} title={title} />;
}

View File

@ -1,6 +1,6 @@
import "./Avatar.css";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useMemo } from "react";
import type { UserMetadata } from "@snort/system";
import classNames from "classnames";
@ -31,10 +31,8 @@ const Avatar = ({
className,
showTitle = true,
}: AvatarProps) => {
const [url, setUrl] = useState("");
useEffect(() => {
setUrl(image ?? user?.picture ?? defaultAvatar(pubkey));
const url = useMemo(() => {
return image ?? user?.picture ?? defaultAvatar(pubkey);
}, [user, image, pubkey]);
const s = size ?? 120;

View File

@ -6,7 +6,6 @@ import { FormattedMessage } from "react-intl";
import { TaggedNostrEvent } from "@snort/system";
import { ProxyImg } from "@/Element/ProxyImg";
import Icon from "@/Icons/Icon";
import Modal from "@/Element/Modal";
import Username from "@/Element/User/Username";
import { findTag } from "@/SnortUtils";
@ -37,9 +36,7 @@ export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
{showModal && (
<Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
<div className="reactions-view">
<div className="close" onClick={() => setShowModal(false)}>
<Icon name="close" />
</div>
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<div className="reactions-header">
<h2>
<FormattedMessage defaultMessage="Badges" id="h8XMJL" />

View File

@ -7,7 +7,7 @@ import classNames from "classnames";
interface DisplayNameProps {
pubkey: HexKey;
user: UserMetadata | undefined;
user?: UserMetadata | undefined;
}
const DisplayName = ({ pubkey }: DisplayNameProps) => {

View File

@ -32,13 +32,14 @@ export default function FollowListBase({
profileActions,
}: FollowListBaseProps) {
const { publisher, system } = useEventPublisher();
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
const login = useLogin();
async function followAll() {
if (publisher) {
const newFollows = dedupe([...pubkeys, ...login.follows.item]);
const newFollows = dedupe([...pubkeys, ...follows.item]);
const ev = await publisher.contactList(newFollows.map(a => ["p", a]));
setFollows(login, newFollows, ev.created_at);
setFollows(id, newFollows, ev.created_at);
await system.BroadcastEvent(ev);
await FollowsFeed.backFill(system, pubkeys);
}
@ -57,7 +58,12 @@ export default function FollowListBase({
)}
<div className={className}>
{pubkeys?.map(a => (
<ProfilePreview pubkey={a} key={a} options={{ about: showAbout }} actions={profileActions?.(a)} />
<ProfilePreview
pubkey={a}
key={a}
options={{ about: showAbout, profileCards: true }}
actions={profileActions?.(a)}
/>
))}
</div>
</div>

View File

@ -19,10 +19,6 @@ a.pfp {
text-decoration: none;
}
.pfp .username {
font-weight: 600;
}
.pfp .profile-name {
max-width: stretch;
max-width: -webkit-fill-available;

View File

@ -10,6 +10,8 @@ import DisplayName from "./DisplayName";
import { ProfileLink } from "./ProfileLink";
import { ProfileCard } from "./ProfileCard";
import FollowDistanceIndicator from "@/Element/User/FollowDistanceIndicator";
import { useCommunityLeader } from "@/Hooks/useCommunityLeaders";
import { LeaderBadge } from "@/Element/CommunityLeaders/LeaderBadge";
export interface ProfileImageProps {
pubkey: HexKey;
@ -27,6 +29,7 @@ export interface ProfileImageProps {
showFollowDistance?: boolean;
icons?: ReactNode;
showProfileCard?: boolean;
showBadges?: boolean;
}
export default function ProfileImage({
@ -42,10 +45,12 @@ export default function ProfileImage({
onClick,
showFollowDistance = true,
icons,
showProfileCard = true,
showProfileCard = false,
showBadges = false,
}: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const [isHovering, setIsHovering] = useState(false);
const leader = useCommunityLeader(pubkey);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -88,8 +93,9 @@ export default function ProfileImage({
</div>
{showUsername && (
<div className="f-ellipsis">
<div className="flex g4 username">
<div className="flex gap-2 items-center font-medium">
{overrideUsername ? overrideUsername : <DisplayName pubkey={pubkey} user={user} />}
{leader && showBadges && CONFIG.features.communityLeaders && <LeaderBadge />}
</div>
<div className="subheader">{subHeader}</div>
</div>

View File

@ -101,4 +101,9 @@ export default defineMessages({
ReBroadcast: { defaultMessage: "Broadcast Again", id: "c3g2hL" },
IrisUserNameLengthError: { defaultMessage: "Name must be between 1 and 32 characters", id: "4MBtMa" },
IrisUserNameFormatError: { defaultMessage: "Username must only contain lowercase letters and numbers", id: "RSr2uB" },
InvalidNip05Address: { defaultMessage: "Invalid Nostr Address", id: "P2o+ZZ" },
ErrorValidatingNip05Address: { defaultMessage: "Cannot verify Nostr Address", id: "LmdPXO" },
UserNameLengthError: { defaultMessage: "Name must be less than {limit} characters", id: "u9NoC1" },
AboutLengthError: { defaultMessage: "About must be less than {limit} characters", id: "DrZqav" },
InvalidLud16: { defaultMessage: "Invalid Lightning Address", id: "GqQeu/" },
});

View File

@ -1,83 +1,20 @@
import { throwIfOffline } from "@snort/shared";
import { NostrEvent } from "@snort/system";
export interface TrendingUser {
pubkey: string;
}
export interface TrendingUserResponse {
profiles: Array<TrendingUser>;
}
export interface TrendingNote {
event: NostrEvent;
author: NostrEvent; // kind0 event
}
export interface TrendingNoteResponse {
notes: Array<TrendingNote>;
}
export interface TrendingHashtagsResponse {
hashtags: Array<{
hashtag: string;
posts: number;
}>;
}
export interface SuggestedFollow {
pubkey: string;
}
export interface SuggestedFollowsResponse {
profiles: Array<SuggestedFollow>;
}
export class NostrBandError extends Error {
body: string;
statusCode: number;
constructor(message: string, body: string, status: number) {
super(message);
this.body = body;
this.statusCode = status;
}
}
export default class NostrBandApi {
readonly #url = "https://api.nostr.band";
readonly #supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"];
async trendingProfiles() {
return await this.#json<TrendingUserResponse>("GET", "/v0/trending/profiles");
trendingProfilesUrl() {
return `${this.#url}/v0/trending/profiles`;
}
async trendingNotes(lang?: string) {
if (lang && this.#supportedLangs.includes(lang)) {
return await this.#json<TrendingNoteResponse>("GET", `/v0/trending/notes?lang=${lang}`);
}
return await this.#json<TrendingNoteResponse>("GET", "/v0/trending/notes");
trendingNotesUrl(lang?: string) {
return `${this.#url}/v0/trending/notes${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
}
async sugguestedFollows(pubkey: string) {
return await this.#json<SuggestedFollowsResponse>("GET", `/v0/suggested/profiles/${pubkey}`);
suggestedFollowsUrl(pubkey: string) {
return `${this.#url}/v0/suggested/profiles/${pubkey}`;
}
async trendingHashtags(lang?: string) {
if (lang && this.#supportedLangs.includes(lang)) {
return await this.#json<TrendingHashtagsResponse>("GET", `/v0/trending/hashtags?lang=${lang}`);
}
return await this.#json<TrendingHashtagsResponse>("GET", "/v0/trending/hashtags");
}
async #json<T>(method: string, path: string) {
throwIfOffline();
const res = await fetch(`${this.#url}${path}`, {
method: method ?? "GET",
});
if (res.ok) {
return (await res.json()) as T;
} else {
throw new NostrBandError("Failed to load content from nostr.band", await res.text(), res.status);
}
trendingHashtagsUrl(lang?: string) {
return `${this.#url}/v0/trending/hashtags${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
}
}

View File

@ -1,46 +1,8 @@
import { throwIfOffline } from "@snort/shared";
export interface RecommendedProfilesResponse {
quality: number;
recommendations: Array<[pubkey: string, score: number]>;
}
export class SemisolDevApiError extends Error {
body: string;
statusCode: number;
constructor(message: string, body: string, status: number) {
super(message);
this.body = body;
this.statusCode = status;
}
}
export default class SemisolDevApi {
readonly #url = "https://api.semisol.dev";
async sugguestedFollows(pubkey: string, follows: Array<string>) {
return await this.#json<RecommendedProfilesResponse>("POST", "/nosgraph/v1/recommend", {
pubkey,
exclude: [],
following: follows,
});
}
async #json<T>(method: string, path: string, body?: unknown) {
throwIfOffline();
const url = `${this.#url}${path}`;
const res = await fetch(url, {
method: method ?? "GET",
body: body ? JSON.stringify(body) : undefined,
headers: {
...(body ? { "content-type": "application/json" } : {}),
},
});
if (res.ok) {
return (await res.json()) as T;
} else {
throw new SemisolDevApiError(`Failed to load content from ${url}`, await res.text(), res.status);
}
suggestedFollowsUrl(pubkey: string, follows: Array<string>) {
const query = new URLSearchParams({ pubkey, follows: JSON.stringify(follows) });
return `${this.#url}/nosgraph/v1/recommend?${query.toString()}`;
}
}

View File

@ -81,6 +81,8 @@ export interface RelayDistance {
export interface RefCodeResponse {
code: string;
pubkey: string;
revShare?: number;
leaderState?: "pending" | "approved";
}
export default class SnortApi {
@ -148,6 +150,10 @@ export default class SnortApi {
return this.#getJson<RefCodeResponse>(`api/v1/referral/${code}`, "GET");
}
applyForLeader() {
return this.#getJsonAuthd<RefCodeResponse>("api/v1/referral/leader-apply", "POST");
}
async #getJsonAuthd<T>(
path: string,
method?: "GET" | string,

View File

@ -104,7 +104,7 @@ export default function useLoginFeed() {
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
setFollows(login, pTags, contactList.created_at * 1000);
setFollows(login.id, pTags, contactList.created_at * 1000);
FollowsFeed.backFillIfMissing(system, pTags);
}

View File

@ -1,43 +1,41 @@
import Fuse from "fuse.js";
import { socialGraphInstance } from "@snort/system";
import { System } from ".";
export type FuzzySearchResult = {
pubkey: string;
name?: string;
username?: string;
display_name?: string;
nip05?: string;
};
export const fuzzySearch = new Fuse<FuzzySearchResult>([], {
keys: ["name", "username", { name: "nip05", weight: 0.5 }],
const fuzzySearch = new Fuse<FuzzySearchResult>([], {
keys: ["name", "display_name", { name: "nip05", weight: 0.5 }],
threshold: 0.3,
// sortFn here?
});
const profileTimestamps = new Map<string, number>(); // is this somewhere in cache?
System.on("event", ev => {
if (ev.kind === 0) {
const existing = profileTimestamps.get(ev.pubkey);
if (existing) {
if (existing > ev.created_at) {
return;
}
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
}
profileTimestamps.set(ev.pubkey, ev.created_at);
try {
const data = JSON.parse(ev.content);
if (ev.pubkey && (data.name || data.username || data.nip05)) {
data.pubkey = ev.pubkey;
fuzzySearch.add(data);
}
} catch (e) {
console.error(e);
}
export const addEventToFuzzySearch = ev => {
if (ev.kind !== 0) {
return;
}
if (ev.kind === 3) {
socialGraphInstance.handleFollowEvent(ev);
const existing = profileTimestamps.get(ev.pubkey);
if (existing) {
if (existing > ev.created_at) {
return;
}
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
}
});
profileTimestamps.set(ev.pubkey, ev.created_at);
try {
const data = JSON.parse(ev.content);
if (ev.pubkey && (data.name || data.display_name || data.nip05)) {
data.pubkey = ev.pubkey;
fuzzySearch.add(data);
}
} catch (e) {
console.error(e);
}
};
export default fuzzySearch;

View File

@ -0,0 +1,46 @@
import { useEffect, useMemo, useState } from "react";
const useCachedFetch = (url, storageKey, dataProcessor = data => data) => {
const cachedData = useMemo(() => {
const cached = localStorage.getItem(storageKey);
return cached ? JSON.parse(cached) : null;
}, [storageKey]);
const initialData = cachedData ? cachedData.data : null;
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(!cachedData);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const fetchedData = await res.json();
const processedData = dataProcessor(fetchedData);
setData(processedData);
localStorage.setItem(storageKey, JSON.stringify({ data: processedData, timestamp: new Date().getTime() }));
} catch (e) {
setError(e);
if (cachedData?.data) {
setData(cachedData.data);
}
} finally {
setIsLoading(false);
}
};
if (!cachedData || (new Date().getTime() - cachedData.timestamp) / 1000 / 60 >= 15) {
fetchData();
}
}, [url, storageKey]);
return { data, isLoading, error };
};
export default useCachedFetch;

View File

@ -0,0 +1,41 @@
import { ExternalStore, unwrap } from "@snort/shared";
import { EventKind, parseNostrLink } from "@snort/system";
import { useLinkList } from "./useLists";
import { useEffect, useSyncExternalStore } from "react";
class CommunityLeadersStore extends ExternalStore<Array<string>> {
#leaders: Array<string> = [];
setLeaders(arr: Array<string>) {
this.#leaders = arr;
this.notifyChange();
}
takeSnapshot(): string[] {
return [...this.#leaders];
}
}
const LeadersStore = new CommunityLeadersStore();
export function useCommunityLeaders() {
const link = parseNostrLink(unwrap(CONFIG.communityLeaders).list);
const list = useLinkList("leaders", rb => {
rb.withFilter().kinds([EventKind.FollowSet]).link(link);
});
useEffect(() => {
console.debug("CommunityLeaders", list);
LeadersStore.setLeaders(list.map(a => a.id));
}, [list]);
}
export function useCommunityLeader(pubkey?: string) {
const store = useSyncExternalStore(
c => LeadersStore.hook(c),
() => LeadersStore.snapshot(),
);
return pubkey && store.includes(pubkey);
}

View File

@ -13,11 +13,11 @@ export default function useImgProxy() {
const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig);
return {
proxy: (url: string, resize?: number) => proxyImg(url, settings, resize),
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256),
};
}
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number) {
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number, sha256?: string) {
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
@ -33,10 +33,17 @@ export function proxyImg(url: string, settings?: ImgProxySettings, resize?: numb
}
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:") || url.length == 0) return url;
const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : "";
const opts = [];
if (sha256) {
opts.push(`hs:sha256:${sha256}`);
}
if (resize) {
opts.push(`rs:fit:${resize}:${resize}`);
opts.push(`dpr:${window.devicePixelRatio}`);
}
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opt}/${urlEncoded}`;
const path = `/${opts.join("/")}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}

View File

@ -0,0 +1,34 @@
import { bech32ToHex } from "@snort/shared";
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
// Snort backend publishes rates
const SnortPubkey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
export function useRates(symbol: string, leaveOpen = true) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`rates:${symbol}`);
rb.withOptions({
leaveOpen,
});
rb.withFilter()
.kinds([1009 as EventKind])
.authors([bech32ToHex(SnortPubkey)])
.tag("d", [symbol])
.limit(1);
return rb;
}, [symbol]);
const data = useRequestBuilder(ReplaceableNoteStore, sub);
const tag = data?.data?.tags.find(a => a[0] === "d" && a[1] === symbol);
if (!tag) return undefined;
return {
time: data.data?.created_at,
ask: Number(tag[2]),
bid: Number(tag[3]),
low: Number(tag[4]),
hight: Number(tag[5]),
};
}

View File

@ -0,0 +1,75 @@
export default function AlbyIcon(props: { size?: number }) {
return (
<svg width={props.size ?? 400} height={props.size ?? 578} viewBox="0 0 400 578" fill="none">
<path
opacity="0.1"
d="M201.283 577.511C255.405 577.511 299.281 569.411 299.281 559.419C299.281 549.427 255.405 541.327 201.283 541.327C147.16 541.327 103.285 549.427 103.285 559.419C103.285 569.411 147.16 577.511 201.283 577.511Z"
fill="black"
/>
<path
d="M295.75 471.344C346.377 471.344 369.42 359.242 369.42 316.736C369.42 283.606 346.56 263.528 316.507 263.528C286.641 263.528 262.394 276.371 262.093 292.275C262.092 334.246 254.705 471.344 295.75 471.344Z"
fill="white"
stroke="black"
stroke-width="15.0766"
/>
<path
d="M110.837 471.344C60.2098 471.344 37.1665 359.242 37.1665 316.736C37.1665 283.606 60.0269 263.528 90.0803 263.528C119.946 263.528 144.193 276.371 144.494 292.275C144.495 334.246 151.882 471.344 110.837 471.344Z"
fill="white"
stroke="black"
stroke-width="15.0766"
/>
<path
d="M68.8309 303.262L68.8307 303.26C68.7764 302.741 68.8817 302.44 68.9894 302.244C69.1165 302.012 69.3578 301.736 69.7632 301.506C70.6022 301.029 71.7772 300.943 72.8713 301.582C110.474 323.624 153.847 336.26 201.001 336.26C248.164 336.26 292.34 323.379 330.185 300.953C331.272 300.308 332.445 300.388 333.287 300.862C333.694 301.091 333.937 301.366 334.066 301.599C334.175 301.796 334.282 302.098 334.229 302.618C328.375 360.632 296.907 408.595 254.611 430.672C240.642 437.965 231.035 450.634 222.598 461.761C222.447 461.961 222.296 462.16 222.146 462.358L222.144 462.36C215.287 471.406 209.081 479.507 201.496 485.476C193.912 479.507 187.705 471.406 180.848 462.36L180.847 462.358C180.697 462.16 180.546 461.961 180.395 461.761C171.958 450.634 162.352 437.965 148.382 430.672C106.247 408.68 74.8589 360.995 68.8309 303.262Z"
fill="#FFDF6F"
stroke="black"
stroke-width="15"
/>
<path
d="M201.786 346.338C275.06 346.338 334.46 326.538 334.46 302.113C334.46 277.688 275.06 257.888 201.786 257.888C128.512 257.888 69.1118 277.688 69.1118 302.113C69.1118 326.538 128.512 346.338 201.786 346.338Z"
fill="black"
stroke="black"
stroke-width="15.0766"
/>
<path
d="M95.2446 376.491C95.2446 376.491 160.685 398.603 202.791 398.603C244.896 398.603 310.337 376.491 310.337 376.491"
stroke="black"
stroke-width="15.0766"
stroke-linecap="round"
/>
<path
d="M77 143C60.4315 143 47 129.569 47 113C47 96.4315 60.4315 83 77 83C93.5685 83 107 96.4315 107 113C107 129.569 93.5685 143 77 143Z"
fill="black"
/>
<path d="M72 108.5L128 164.5" stroke="black" stroke-width="15" />
<path
d="M322 143C338.569 143 352 129.569 352 113C352 96.4315 338.569 83 322 83C305.431 83 292 96.4315 292 113C292 129.569 305.431 143 322 143Z"
fill="black"
/>
<path d="M327.5 108.5L271.5 164.5" stroke="black" stroke-width="15" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.5155 292.019C69.3466 284.321 59.9364 267.036 63.0886 249.407C76.6177 173.747 133 117 200.5 117C268.163 117 324.655 174.023 338.009 249.958C341.115 267.618 331.628 284.895 315.404 292.53C280.687 308.868 241.91 318 201 318C159.665 318 120.507 308.677 85.5155 292.019Z"
fill="#FFDF6F"
/>
<path
d="M70.4715 250.728C83.5443 177.62 137.582 124.5 200.5 124.5V109.5C128.418 109.5 69.6912 169.875 55.7057 248.087L70.4715 250.728ZM200.5 124.5C263.569 124.5 317.718 177.879 330.622 251.257L345.396 248.659C331.592 170.166 272.758 109.5 200.5 109.5V124.5ZM312.21 285.744C278.472 301.621 240.783 310.5 201 310.5V325.5C243.037 325.5 282.902 316.114 318.597 299.317L312.21 285.744ZM201 310.5C160.804 310.5 122.745 301.436 88.7393 285.247L82.2918 298.791C118.269 315.918 158.526 325.5 201 325.5V310.5ZM330.622 251.257C333.112 265.416 325.531 279.476 312.21 285.744L318.597 299.317C337.725 290.315 349.117 269.82 345.396 248.659L330.622 251.257ZM55.7057 248.087C51.9285 269.211 63.2298 289.716 82.2918 298.791L88.7393 285.247C75.4633 278.927 67.9443 264.86 70.4715 250.728L55.7057 248.087Z"
fill="black"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M114.365 273.209C101.35 267.908 93.6293 254.06 98.1392 240.75C112.047 199.704 152.618 170 200.5 170C248.382 170 288.953 199.704 302.861 240.75C307.371 254.06 299.65 267.908 286.635 273.209C260.053 284.035 230.973 290 200.5 290C170.027 290 140.947 284.035 114.365 273.209Z"
fill="black"
/>
<path
d="M235 254C248.807 254 260 245.046 260 234C260 222.954 248.807 214 235 214C221.193 214 210 222.954 210 234C210 245.046 221.193 254 235 254Z"
fill="white"
/>
<path
d="M163.432 254.012C177.239 254.012 188.432 245.058 188.432 234.012C188.432 222.966 177.239 214.012 163.432 214.012C149.625 214.012 138.432 222.966 138.432 234.012C138.432 245.058 149.625 254.012 163.432 254.012Z"
fill="white"
/>
</svg>
);
}

View File

@ -0,0 +1,48 @@
export default function CashuIcon(props: { size?: number }) {
return (
<svg width={props.size ?? 135} height={props.size ?? 153} viewBox="0 0 135 153">
<path
d="m 18,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 0,9 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 Z M 9,18 H 8 7 6 5 4 3 2 1 0 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 H 1 2 3 4 5 6 7 8 9 V 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 0,53 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 H 8 7 6 V 60 59 58 57 H 5 4 3 V 56 55 54 53 H 2 1 Z m 9,55 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 81,0 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 z"
style={{
fill: "#b89563",
}}
/>
<path
d="m 36,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 45,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 z m 0,27 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 44 43 42 41 40 39 38 37 Z M 63,64 v 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,8 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 36,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z"
style={{
fill: "#e2d2b3",
}}
/>
<path
d="m 18,9 v 1 1 1 1 1 1 1 1 1 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 9 H 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 9,61 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 z"
style={{
fill: "#c5a77f",
}}
/>
<path
d="m 36,9 v 1 1 1 1 1 1 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 9,40 v 1 1 1 1 1 1 h -1 -1 v 1 1 1 h -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 v -1 -1 -1 h -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 z"
style={{
fill: "#dbbf98",
}}
/>
<path
d="m 0,45 v 1 1 1 1 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 h 1 1 1 v -1 -1 -1 h 1 1 v -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 V 52 51 50 49 48 47 46 45 H 95 94 93 92 91 90 89 88 87 86 85 84 83 82 81 80 79 78 77 76 75 74 73 72 71 70 69 68 67 66 65 64 63 62 61 60 59 58 57 56 55 54 53 52 51 50 49 48 47 46 45 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 Z m 6,4 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 H 17 16 15 14 V 60 59 58 57 H 13 12 11 10 V 56 55 54 53 H 9 8 7 6 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 44,-8 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z"
style={{
fill: "#000000",
}}
/>
<path
d="m 6,49 v 1 1 1 1 h 1 1 1 1 V 52 51 50 49 H 9 8 7 Z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z m 40,-8 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z"
style={{
fill: "#ffffff",
}}
/>
<path
d="m 99,99 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z"
style={{
fill: "#f7f8f3",
}}
/>
</svg>
);
}

View File

@ -12,7 +12,7 @@ import { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { Blasters, SnortPubKey } from "@/Const";
import { Blasters } from "@/Const";
import { LoginStore, UserPreferences, LoginSession, LoginSessionType, SnortAppData, Newest } from "@/Login";
import { generateBip39Entropy, entropyToPrivateKey } from "@/nip6";
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/SnortUtils";
@ -124,7 +124,8 @@ export async function generateNewLogin(
const publisher = EventPublisher.privateKey(privateKey);
// Create new contact list following self and site account
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey].map(a => ["p", a]));
const contactList = [publicKey, ...CONFIG.signUp.defaultFollows.map(a => bech32ToHex(a))].map(a => ["p", a]);
const ev = await publisher.contactList(contactList);
system.BroadcastEvent(ev);
// Create relay metadata event
@ -176,13 +177,15 @@ export function setBlocked(state: LoginSession, blocked: Array<string>, ts: numb
LoginStore.updateSession(state);
}
export function setFollows(state: LoginSession, follows: Array<string>, ts: number) {
if (state.follows.timestamp >= ts) {
return;
export function setFollows(id: string, follows: Array<string>, ts: number) {
const session = LoginStore.get(id);
if (session) {
if (ts > session.follows.timestamp) {
session.follows.item = follows;
session.follows.timestamp = ts;
LoginStore.updateSession(session);
}
}
state.follows.item = follows;
state.follows.timestamp = ts;
LoginStore.updateSession(state);
}
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) {

View File

@ -3,9 +3,8 @@ import * as utils from "@noble/curves/abstract/utils";
import { v4 as uuid } from "uuid";
import { HexKey, RelaySettings, EventPublisher, KeyStorage, NotEncrypted, socialGraphInstance } from "@snort/system";
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { deepClone, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "@/Const";
import { LoginSession, LoginSessionType, createPublisher } from "@/Login";
import { DefaultPreferences, UserPreferences } from "./Preferences";
@ -39,7 +38,7 @@ const LoggedOut = {
timestamp: 0,
},
relays: {
item: Object.fromEntries([...DefaultRelays.entries()].map(a => [unwrap(sanitizeRelayUrl(a[0])), a[1]])),
item: CONFIG.defaultRelays,
timestamp: 0,
},
latestNotification: 0,
@ -49,6 +48,7 @@ const LoggedOut = {
item: {
mutedWords: [],
preferences: DefaultPreferences,
showContentWarningPosts: false,
},
timestamp: 0,
},
@ -179,7 +179,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (relays && Object.keys(relays).length > 0) {
return relays;
}
return Object.fromEntries(DefaultRelays.entries());
return CONFIG.defaultRelays;
}
loginWithPrivateKey(key: KeyStorage, entropy?: string, relays?: Record<string, RelaySettings>) {

View File

@ -114,6 +114,6 @@ export const DefaultPreferences = {
telemetry: true,
showBadges: false,
showStatus: true,
checkSigs: false,
checkSigs: true,
autoTranslate: true,
} as UserPreferences;

View File

@ -1,9 +1,11 @@
import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system";
import { TaggedNostrEvent, EventKind, MetadataCache, EventPublisher } from "@snort/system";
import { MentionRegex } from "@/Const";
import { defaultAvatar, tagFilterOfTextRepost, getDisplayName } from "@/SnortUtils";
import { UserCache } from "@/Cache";
import { LoginSession } from "@/Login";
import { removeUndefined } from "@snort/shared";
import { removeUndefined, unwrap } from "@snort/shared";
import SnortApi from "@/External/SnortApi";
import { base64 } from "@scure/base";
export interface NotificationRequest {
title: string;
@ -69,3 +71,37 @@ export async function sendNotification(state: LoginSession, req: NotificationReq
}
}
}
export async function subscribeToNotifications(publisher: EventPublisher) {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
}
}
} catch (e) {
console.error(e);
}
}

View File

@ -71,7 +71,7 @@ export function SnortDeckLayout() {
id="IOu4Xh"
values={{
app: CONFIG.appNameCapitalized,
tier: mapPlanName(CONFIG.deckSubKind),
tier: mapPlanName(CONFIG.deckSubKind ?? -1),
}}
/>
</div>
@ -123,9 +123,9 @@ export function SnortDeckLayout() {
{deckState.article && (
<>
<Modal
id="thread-overlay-article"
id="deck-article"
onClose={() => setDeckState({})}
className="thread-overlay long-form"
className="long-form"
onClick={() => setDeckState({})}>
<div onClick={e => e.stopPropagation()}>
<LongFormText ev={deckState.article} isPreview={false} related={[]} />
@ -144,11 +144,11 @@ function NotesCol() {
return (
<div>
<div className="deck-col-header flex">
<div className="flex f-1 g8">
<div className="flex flex-1 g8">
<Icon name="rows-01" size={24} />
<FormattedMessage defaultMessage="Notes" id="7+Domh" />
</div>
<div className="f-1">
<div className="flex-1">
<RootTabs base="/deck" />
</div>
</div>

View File

@ -1,17 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/system";
import { ApiHost, DeveloperAccounts, SnortPubKey } from "@/Const";
import ProfilePreview from "@/Element/User/ProfilePreview";
import ZapButton from "@/Element/Event/ZapButton";
import { bech32ToHex } from "@/SnortUtils";
import { bech32ToHex, unwrap } from "@/SnortUtils";
import SnortApi, { RevenueSplit, RevenueToday } from "@/External/SnortApi";
import Modal from "@/Element/Modal";
import AsyncButton from "@/Element/Button/AsyncButton";
import QrCode from "@/Element/QrCode";
import Copy from "@/Element/Copy";
import { Link } from "react-router-dom";
import { ZapPoolController, ZapPoolRecipientType } from "@/ZapPoolController";
import { ZapPoolTarget } from "./ZapPool";
const Contributors = [
bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan
@ -59,6 +61,10 @@ const DonatePage = () => {
const [today, setSumToday] = useState<RevenueToday>();
const [onChain, setOnChain] = useState("");
const api = new SnortApi(ApiHost);
const zapPool = useSyncExternalStore(
c => unwrap(ZapPoolController).hook(c),
() => unwrap(ZapPoolController).snapshot(),
);
async function getOnChainAddress() {
const { address } = await api.onChainDonation();
@ -95,14 +101,11 @@ const DonatePage = () => {
</h2>
<p>
<FormattedMessage
defaultMessage="{site} is an open source project built by passionate people in their free time"
id="6TfgXX"
defaultMessage="{site} is an open source project built by passionate people in their free time, your donations are greatly appreciated"
id="XhpBfA"
values={{ site: CONFIG.appNameCapitalized }}
/>
</p>
<p>
<FormattedMessage defaultMessage="Your donations are greatly appreciated" id="nn1qb3" />
</p>
<p>
<FormattedMessage
defaultMessage="Check out the code here: {link}"
@ -130,10 +133,9 @@ const DonatePage = () => {
/>
</p>
<p>
<FormattedMessage
defaultMessage="Each contributor will get paid a percentage of all donations and NIP-05 orders, you can see the split amounts below"
id="mH91FY"
/>
<a href="https://t.me/irismessenger" target="_blank" rel="noreferrer" className="underline">
Telegram
</a>
</p>
<div className="flex flex-col g12">
<div className="b br p">
@ -173,6 +175,34 @@ const DonatePage = () => {
</div>
</Modal>
)}
{CONFIG.features.zapPool && (
<>
<h3>
<FormattedMessage defaultMessage="ZapPool" id="pRess9" />
</h3>
<p>
<FormattedMessage
defaultMessage="Fund the services that you use by splitting a portion of all your zaps into a pool of funds!"
id="x/Fx2P"
/>
</p>
<p>
<Link to="/zap-pool" className="underline">
<FormattedMessage defaultMessage="Configure zap pool" id="kqPQJD" />
</Link>
</p>
<ZapPoolTarget
target={
zapPool.find(b => b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? {
type: ZapPoolRecipientType.Generic,
pubkey: bech32ToHex(SnortPubKey),
split: 0,
sum: 0,
}
}
/>
</>
)}
<h3>
<FormattedMessage defaultMessage="Primary Developers" id="4IPzdn" />
</h3>

View File

@ -49,8 +49,8 @@ const Footer = () => {
return (
<footer className="md:hidden fixed bottom-0 z-10 w-full bg-base-200 pb-safe-area bg-bg-color">
<div className="flex">
{MENU_ITEMS.map(item => (
<FooterNavItem item={item} readonly={readonly} />
{MENU_ITEMS.map((item, index) => (
<FooterNavItem key={index} item={item} readonly={readonly} />
))}
{publicKey && (
<ProfileLink

View File

@ -1,5 +1,5 @@
import { useLocation, useNavigate } from "react-router-dom";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo } from "react";
import classNames from "classnames";
import { LogoHeader } from "@/Pages/Layout/LogoHeader";
import { rootTabItems, RootTabs } from "@/Element/Feed/RootTabs";
@ -16,7 +16,15 @@ export function Header() {
const navigate = useNavigate();
const location = useLocation();
const pageName = decodeURIComponent(location.pathname.split("/")[1]);
const [nostrLink, setNostrLink] = useState<NostrLink | undefined>();
const nostrLink = useMemo(() => {
try {
return parseNostrLink(pageName);
} catch (e) {
return undefined;
}
}, [pageName]);
const { publicKey, tags } = useLogin();
const isRootTab = useMemo(() => {
@ -27,14 +35,6 @@ export function Header() {
window.scrollTo({ top: 0, behavior: "instant" });
}, []);
useEffect(() => {
try {
setNostrLink(parseNostrLink(pageName));
} catch (e) {
setNostrLink(undefined);
}
}, [pageName]);
const handleBackButtonClick = () => {
const idx = window.history.state?.idx;
if (idx === undefined || idx > 0) {
@ -71,7 +71,7 @@ export function Header() {
<header
className={classNames(
{ "md:hidden": pageName === "messages" },
"flex justify-between items-center self-stretch gap-6 sticky top-0 z-10 bg-bg-color md:bg-header md:bg-opacity-50 md:shadow-lg md:backdrop-blur-lg",
"flex justify-between items-center self-stretch gap-6 sticky top-0 z-10 bg-bg-color md:bg-header md:bg-opacity-50 md:backdrop-blur-lg",
)}>
<div
onClick={handleBackButtonClick}
@ -87,7 +87,7 @@ export function Header() {
{!isRootTab && (
<div
onClick={scrollUp}
className="cursor-pointer flex-1 text-center p-2 overflow-hidden whitespace-nowrap truncate">
className="cursor-pointer flex-1 text-center p-2 overflow-hidden whitespace-nowrap truncate md:text-lg">
{title}
</div>
)}

View File

@ -1,16 +1,21 @@
import { LogoHeader } from "./LogoHeader";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import Icon from "@/Icons/Icon";
import { ProfileLink } from "../../Element/User/ProfileLink";
import Avatar from "../../Element/User/Avatar";
import useLogin from "../../Hooks/useLogin";
import { useUserProfile } from "@snort/system-react";
import { NoteCreatorButton } from "../../Element/Event/Create/NoteCreatorButton";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import classNames from "classnames";
import { getCurrentSubscription } from "@/Subscription";
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
import NavLink from "@/Element/Button/NavLink";
import { subscribeToNotifications } from "@/Notifications";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { Sats, useWallet } from "@/Wallet";
import { useEffect, useState } from "react";
import { useRates } from "@/Hooks/useRates";
const MENU_ITEMS = [
{
@ -34,6 +39,7 @@ const MENU_ITEMS = [
label: "Messages",
icon: "mail",
link: "/messages",
hideReadOnly: true,
},
{
label: "Deck",
@ -69,6 +75,44 @@ const getNavLinkClass = (isActive: boolean, narrow: boolean) => {
});
};
const WalletBalance = () => {
const [balance, setBalance] = useState<Sats>();
const wallet = useWallet();
const rates = useRates("BTCUSD");
useEffect(() => {
setBalance(undefined);
if (wallet.wallet && wallet.wallet.canGetBalance()) {
wallet.wallet.getBalance().then(setBalance);
}
}, [wallet]);
return (
<div className="w-max flex flex-col max-xl:hidden">
<div className="grow flex items-center justify-between">
<div className="flex gap-1 items-center">
<Icon name="sats" size={24} />
<FormattedNumber value={balance ?? 0} />
</div>
<Link to="/wallet">
<Icon name="dots" className="text-secondary" />
</Link>
</div>
<div className="text-secondary text-sm">
<FormattedMessage
defaultMessage="~{amount}"
id="3QwfJR"
values={{
amount: (
<FormattedNumber style="currency" currency="USD" value={(rates?.ask ?? 0) * (balance ?? 0) * 1e-8} />
),
}}
/>
</div>
</div>
);
};
export default function NavSidebar({ narrow = false }) {
const { publicKey, subscriptions, readonly } = useLogin(s => ({
publicKey: s.publicKey,
@ -77,6 +121,7 @@ export default function NavSidebar({ narrow = false }) {
}));
const profile = useUserProfile(publicKey);
const navigate = useNavigate();
const { publisher } = useEventPublisher();
const sub = getCurrentSubscription(subscriptions);
const { formatMessage } = useIntl();
@ -100,8 +145,9 @@ export default function NavSidebar({ narrow = false }) {
<div
className={classNames(
{ "xl:items-start": !narrow, "xl:gap-2": !narrow },
"gap-1 flex flex-col items-center text-lg",
"gap-1 flex flex-col items-center text-lg font-bold",
)}>
<WalletBalance narrow={narrow} />
{MENU_ITEMS.filter(a => {
if ((CONFIG.hideFromNavbar ?? []).includes(a.link)) {
return false;
@ -109,13 +155,25 @@ export default function NavSidebar({ narrow = false }) {
if (a.link == "/deck" && !showDeck) {
return false;
}
if (readonly && a.hideReadOnly) {
return false;
}
return true;
}).map(item => {
if (!item.nonLoggedIn && !publicKey) {
return "";
}
const onClick = () => {
if (item.label === "Notifications" && publisher) {
subscribeToNotifications(publisher);
}
};
return (
<NavLink key={item.link} to={item.link} className={({ isActive }) => getNavLinkClass(isActive, narrow)}>
<NavLink
onClick={onClick}
key={item.link}
to={item.link}
className={({ isActive }) => getNavLinkClass(isActive, narrow)}>
<Icon name={`${item.icon}-outline`} className="icon-outline" size={24} />
<Icon name={`${item.icon}-solid`} className="icon-solid" size={24} />
{item.label === "Notifications" && <HasNotificationsMarker />}

View File

@ -1,15 +1,13 @@
import { useNavigate } from "react-router-dom";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import Icon from "@/Icons/Icon";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import { isFormElement } from "@/SnortUtils";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import SnortApi from "@/External/SnortApi";
import { HasNotificationsMarker } from "@/Pages/Layout/HasNotificationsMarker";
import NavLink from "@/Element/Button/NavLink";
import classNames from "classnames";
import { subscribeToNotifications } from "@/Notifications";
const NotificationsHeader = () => {
const navigate = useNavigate();
@ -27,40 +25,6 @@ const NotificationsHeader = () => {
}));
const { publisher } = useEventPublisher();
async function goToNotifications() {
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
scope: `${location.protocol}//${location.hostname}`,
});
}
}
} catch (e) {
console.error(e);
}
}
if (!publicKey) {
return (
<button onClick={() => navigate("/login/sign-up")} className="mr-3 primary p-2">
@ -73,7 +37,7 @@ const NotificationsHeader = () => {
<NavLink
className={({ isActive }) => classNames({ active: isActive }, "px-2 py-3 flex")}
to="/notifications"
onClick={goToNotifications}>
onClick={() => subscribeToNotifications(publisher)}>
<Icon name="bell-solid" className="icon-solid" size={24} />
<Icon name="bell-outline" className="icon-outline" size={24} />
<HasNotificationsMarker />

View File

@ -7,7 +7,7 @@ import useLogin from "@/Hooks/useLogin";
export default function RightColumn() {
const { pubkey } = useLogin(s => ({ pubkey: s.publicKey }));
const hideRightColumnPaths = ["/login", "/new", "/messages", "/settings"];
const hideRightColumnPaths = ["/login", "/new", "/messages"];
const show = !hideRightColumnPaths.some(path => location.pathname.startsWith(path));
const getTitleMessage = () => {
@ -33,7 +33,7 @@ export default function RightColumn() {
<div>
<SearchBox />
</div>
<div className="font-bold text-lg mt-4 mb-2">{getTitleMessage()}</div>
<div className="font-bold text-xs mt-4 mb-2 uppercase tracking-wide">{getTitleMessage()}</div>
<div className="overflow-y-auto hide-scrollbar flex-grow rounded-lg">{getContent()}</div>
</div>
);

View File

@ -2,7 +2,6 @@ import "./Layout.css";
import { useCallback } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Icon from "@/Icons/Icon";
import useLogin from "@/Hooks/useLogin";
import { isFormElement } from "@/SnortUtils";
import Toaster from "@/Toaster";
@ -17,6 +16,8 @@ import useLoginFeed from "@/Feed/LoginFeed";
import ErrorBoundary from "@/Element/ErrorBoundary";
import Footer from "@/Pages/Layout/Footer";
import { Header } from "@/Pages/Layout/Header";
import CloseButton from "@/Element/Button/CloseButton";
import { useCommunityLeaders } from "@/Hooks/useCommunityLeaders";
export default function Index() {
const location = useLocation();
@ -25,6 +26,9 @@ export default function Index() {
useTheme();
useLoginRelays();
useLoginFeed();
if (CONFIG.features.communityLeaders) {
useCommunityLeaders();
}
const hideHeaderPaths = ["/login", "/new"];
const shouldHideFooter = location.pathname.startsWith("/messages/");
@ -66,9 +70,7 @@ export default function Index() {
function StalkerModal({ id }) {
return (
<div className="stalker" onClick={() => LoginStore.removeSession(id)}>
<button type="button" className="circle flex items-center">
<Icon name="close" />
</button>
<CloseButton />
</div>
);
}

View File

@ -1,8 +1,8 @@
import { MetadataCache } from "@snort/system";
import { ChatParticipant } from "@/chat";
import NoteToSelf from "../User/NoteToSelf";
import ProfileImage from "../User/ProfileImage";
import NoteToSelf from "../../Element/User/NoteToSelf";
import ProfileImage from "../../Element/User/ProfileImage";
import useLogin from "@/Hooks/useLogin";
export function ChatParticipantProfile({ participant }: { participant: ChatParticipant }) {

View File

@ -9,9 +9,9 @@ import NoteTime from "@/Element/Event/NoteTime";
import Text from "@/Element/Text";
import useLogin from "@/Hooks/useLogin";
import { Chat, ChatMessage, ChatType, setLastReadIn } from "@/chat";
import ProfileImage from "../User/ProfileImage";
import ProfileImage from "../../Element/User/ProfileImage";
import messages from "../messages";
import messages from "../../Element/messages";
export interface DMProps {
chat: Chat;

View File

@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef } from "react";
import ProfileImage from "@/Element/User/ProfileImage";
import DM from "@/Element/Chat/DM";
import DM from "@/Pages/Messages/DM";
import useLogin from "@/Hooks/useLogin";
import WriteMessage from "@/Element/Chat/WriteMessage";
import WriteMessage from "@/Pages/Messages/WriteMessage";
import { Chat, createEmptyChatObject, useChatSystem } from "@/chat";
import { FormattedMessage } from "react-intl";
import { ChatParticipantProfile } from "./ChatParticipant";

View File

@ -9,9 +9,9 @@ import NoteToSelf from "@/Element/User/NoteToSelf";
import useLogin from "@/Hooks/useLogin";
import usePageWidth from "@/Hooks/usePageWidth";
import NoteTime from "@/Element/Event/NoteTime";
import DmWindow from "@/Element/Chat/DmWindow";
import DmWindow from "@/Pages/Messages/DmWindow";
import { Chat, ChatType, useChatSystem } from "@/chat";
import { ChatParticipantProfile } from "@/Element/Chat/ChatParticipant";
import { ChatParticipantProfile } from "@/Pages/Messages/ChatParticipant";
import classNames from "classnames";
import NewChatWindow from "@/Pages/Messages/NewChatWindow";
@ -109,7 +109,7 @@ export default function MessagesPage() {
.map(conversation)}
</div>
)}
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div className="flex-1"></div>}
{chat ? <DmWindow id={chat} /> : pageWidth >= TwoCol && <div className="flex-1 rt-border"></div>}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import useEventPublisher from "@/Hooks/useEventPublisher";
import Textarea from "../Textarea";
import Textarea from "../../Element/Textarea";
import { Chat } from "@/chat";
import { AsyncIcon } from "@/Element/Button/AsyncIcon";

View File

@ -1,55 +1,60 @@
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation, useParams } from "react-router-dom";
import React, { useEffect, useState } from "react";
import { useParams, useLocation } from "react-router-dom";
import { fetchNip05Pubkey } from "@snort/shared";
import Spinner from "@/Icons/Spinner";
import ProfilePage from "@/Pages/Profile/ProfilePage";
import { ThreadRoute } from "@/Element/Event/Thread";
import { GenericFeed } from "@/Element/Feed/Generic";
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { FormattedMessage } from "react-intl";
export default function NostrLinkHandler() {
const params = useParams();
const { state } = useLocation();
const [loading, setLoading] = useState(true);
const [renderComponent, setRenderComponent] = useState<React.ReactNode>(null);
const { link } = useParams();
const link = decodeURIComponent(params["*"] ?? "").toLowerCase();
async function handleLink(link: string) {
const determineInitialComponent = link => {
const nav = tryParseNostrLink(link);
if (nav) {
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
setRenderComponent(<ThreadRoute key={link} id={nav.encode()} />); // Directly render ThreadRoute
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
const id = nav.encode();
setRenderComponent(<ProfilePage key={id} id={id} state={state} />); // Directly render ProfilePage
} else if (nav.type === NostrPrefix.Req) {
setRenderComponent(<GenericFeed key={link} link={nav} />);
switch (nav.type) {
case NostrPrefix.Event:
case NostrPrefix.Note:
case NostrPrefix.Address:
return <ThreadRoute key={link} id={nav.encode()} />;
case NostrPrefix.PublicKey:
case NostrPrefix.Profile:
return <ProfilePage key={link} id={nav.encode()} state={state} />;
case NostrPrefix.Req:
return <GenericFeed key={link} link={nav} />;
default:
return null;
}
} else {
if (state) {
setRenderComponent(<ProfilePage key={link} state={state} />); // Directly render ProfilePage from route state
} else {
try {
const pubkey = await fetchNip05Pubkey(link, CONFIG.nip05Domain);
if (pubkey) {
setRenderComponent(<ProfilePage key={link} id={pubkey} state={state} />); // Directly render ProfilePage
}
} catch {
//ignored
}
}
return state ? <ProfilePage key={link} state={state} /> : null;
}
};
const initialRenderComponent = determineInitialComponent(link);
const [loading, setLoading] = useState(initialRenderComponent ? false : true);
const [renderComponent, setRenderComponent] = useState(initialRenderComponent);
async function handleLink(link) {
if (!tryParseNostrLink(link)) {
try {
const pubkey = await fetchNip05Pubkey(link, CONFIG.nip05Domain);
if (pubkey) {
setRenderComponent(<ProfilePage key={link} id={pubkey} state={state} />);
}
} catch {
// Ignored
}
setLoading(false);
}
setLoading(false);
}
useEffect(() => {
if (link.length > 0) {
handleLink(link).catch(console.error);
}
}, [link]);
setRenderComponent(determineInitialComponent(link));
handleLink(link);
}, [link]); // Depend only on 'link'
if (renderComponent) {
return renderComponent;

View File

@ -98,9 +98,11 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
return (
<>
<div className="main-content">
<Suspense fallback={<PageSpinner />}>
<NotificationGraph evs={myNotifications} />
</Suspense>
{CONFIG.features.notificationGraph && (
<Suspense fallback={<PageSpinner />}>
<NotificationGraph evs={myNotifications} />
</Suspense>
)}
{login.publicKey &&
[...timeGrouped.entries()]
.slice(0, showN)

View File

@ -166,10 +166,6 @@
border: none;
}
.qr-modal .pfp .username {
align-items: center;
}
.qr-modal canvas {
border-radius: 10px;
}

View File

@ -1,7 +1,7 @@
import "./ProfilePage.css";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import {
encodeTLVEntries,
EventKind,
@ -55,8 +55,6 @@ import ProfileTab, {
import DisplayName from "@/Element/User/DisplayName";
import { UserWebsiteLink } from "@/Element/User/UserWebsiteLink";
import { useMuteList, usePinList } from "@/Hooks/useLists";
import messages from "../messages";
import FollowedBy from "@/Element/User/FollowedBy";
interface ProfilePageProps {
@ -323,9 +321,16 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
)}
{isMe ? (
<>
<button className="md:hidden" type="button" onClick={() => navigate("/settings")}>
<FormattedMessage {...messages.Settings} />
</button>
<Link className="md:hidden" to="/settings">
<button>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</button>
</Link>
<Link className="hidden md:inline" to="/settings/profile">
<button>
<FormattedMessage defaultMessage="Edit" id="wEQDC6" />
</button>
</Link>
</>
) : (
<>
@ -394,7 +399,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
</div>
</div>
<div className="main-content">{tabContent()}</div>
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} images={[modalImage]} idx={0} />}
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} media={[modalImage]} idx={0} />}
</>
);
}

View File

@ -10,7 +10,6 @@ import { TimelineSubject } from "@/Feed/TimelineFeed";
import { debounce, getCurrentRefCode, getRelayName, sha256 } from "@/SnortUtils";
import useLogin from "@/Hooks/useLogin";
import Discover from "@/Pages/Discover";
import TrendingUsers from "@/Element/Trending/TrendingUsers";
import TrendingNotes from "@/Element/Trending/TrendingPosts";
import HashTagsPage from "@/Pages/HashTagsPage";
import SuggestedProfiles from "@/Element/SuggestedProfiles";
@ -147,6 +146,18 @@ export const GlobalTab = () => {
);
};
export const FollowedByFriendsTab = () => {
const { publicKey } = useLogin();
const subject: TimelineSubject = {
type: "global",
items: [],
discriminator: `followed-by-friends-${publicKey}`,
streams: true,
};
return <Timeline followDistance={2} subject={subject} postsOnly={true} method={"TIME_RANGE"} />;
};
export const NotesTab = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const deckContext = useContext(DeckContext);
@ -209,6 +220,10 @@ export const RootTabRoutes = [
path: "notes",
element: <NotesTab />,
},
{
path: "followed-by-friends",
element: <FollowedByFriendsTab />,
},
{
path: "conversations",
element: <ConversationsTab />,
@ -225,14 +240,6 @@ export const RootTabRoutes = [
path: "trending/notes",
element: <TrendingNotes />,
},
{
path: "trending/people",
element: (
<div className="p">
<TrendingUsers />
</div>
),
},
{
path: "trending/hashtags",
element: <TrendingHashtags />,

View File

@ -1,42 +1,42 @@
import "./WalletPage.css";
import { useEffect, useState } from "react";
import { RouteObject, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import NoteTime from "@/Element/Event/NoteTime";
import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "@/Wallet";
import { WalletInvoice, Sats, useWallet, LNWallet, Wallets } from "@/Wallet";
import AsyncButton from "@/Element/Button/AsyncButton";
import { unwrap } from "@/SnortUtils";
import { WebLNWallet } from "@/Wallet/WebLN";
import Icon from "@/Icons/Icon";
import { useRates } from "@/Hooks/useRates";
import { AsyncIcon } from "@/Element/Button/AsyncIcon";
import classNames from "classnames";
export const WalletRoutes: RouteObject[] = [
{
path: "/wallet",
element: <WalletPage />,
},
];
export default function WalletPage() {
export default function WalletPage(props: { showHistory: boolean }) {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const [info, setInfo] = useState<WalletInfo>();
const [balance, setBalance] = useState<Sats>();
const [history, setHistory] = useState<WalletInvoice[]>();
const [walletPassword, setWalletPassword] = useState<string>();
const [error, setError] = useState<string>();
const walletState = useWallet();
const wallet = walletState.wallet;
const rates = useRates("BTCUSD");
async function loadWallet(wallet: LNWallet) {
try {
const i = await wallet.getInfo();
setInfo(i);
const b = await wallet.getBalance();
setBalance(b as Sats);
const h = await wallet.getInvoices();
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
setError(undefined);
setBalance(0);
setHistory(undefined);
if (wallet.canGetBalance()) {
const b = await wallet.getBalance();
setBalance(b as Sats);
}
if (wallet.canGetInvoices() && (props.showHistory ?? true)) {
const h = await wallet.getInvoices();
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
}
} catch (e) {
if (e instanceof Error) {
setError((e as Error).message);
@ -47,29 +47,11 @@ export default function WalletPage() {
}
useEffect(() => {
if (wallet) {
if (wallet.isReady()) {
loadWallet(wallet).catch(console.warn);
} else if (wallet.canAutoLogin()) {
wallet
.login()
.then(async () => await loadWallet(wallet))
.catch(console.warn);
}
if (wallet && wallet.isReady()) {
loadWallet(wallet).catch(console.warn);
}
}, [wallet]);
function stateIcon(s: WalletInvoiceState) {
switch (s) {
case WalletInvoiceState.Pending:
return <Icon name="clock" className="mr5" size={15} />;
case WalletInvoiceState.Paid:
return <Icon name="check" className="mr5" size={15} />;
case WalletInvoiceState.Expired:
return <Icon name="close" className="mr5" size={15} />;
}
}
async function loginWallet(pw: string) {
if (wallet) {
await wallet.login(pw);
@ -116,11 +98,11 @@ export default function WalletPage() {
);
}
return (
<div className="flex w-max">
<h4 className="f-1">
<div className="flex items-center">
<h4 className="grow">
<FormattedMessage defaultMessage="Select Wallet" id="G1BGCg" />
</h4>
<div className="f-1">
<div>
<select className="w-max" onChange={e => Wallets.switch(e.target.value)} value={walletState.config?.id}>
{Wallets.list().map(a => {
return <option value={a.id}>{a.info.alias}</option>;
@ -132,84 +114,119 @@ export default function WalletPage() {
}
function walletHistory() {
if (wallet instanceof WebLNWallet) return null;
if (!wallet?.canGetInvoices() || !(props.showHistory ?? true)) return;
return (
<>
<div className="flex flex-col gap-1">
<h3>
<FormattedMessage defaultMessage="History" id="d6CyG5" description="Wallet transation history" />
<FormattedMessage defaultMessage="Payments" id="pukxg/" description="Wallet transation history" />
</h3>
{history?.map(a => (
<div className="card flex wallet-history-item" key={a.timestamp}>
<div className="grow flex-col">
<NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })} />
<div>{(a.memo ?? "").length === 0 ? <>&nbsp;</> : a.memo}</div>
{history?.map(a => {
const dirClassname = {
"text-[--success]": a.direction === "in",
"text-[--error]": a.direction === "out",
};
return (
<div className="flex gap-4 p-2 hover:bg-[--gray-superdark] rounded-xl items-center" key={a.timestamp}>
<div>
<div className="rounded-full aspect-square p-2 bg-[--gray-dark]">
<Icon
name="arrow-up-right"
className={classNames(dirClassname, {
"rotate-180": a.direction === "in",
})}
/>
</div>
</div>
<div className="grow flex justify-between">
<div className="flex flex-col gap-1">
<div>{a.memo?.length === 0 ? CONFIG.appNameCapitalized : a.memo}</div>
<div className="text-secondary text-sm">
<NoteTime
from={a.timestamp * 1000}
fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })}
/>
</div>
</div>
<div className="flex flex-col gap-1 text-right">
<div className={classNames(dirClassname)}>
<FormattedMessage
defaultMessage="{sign} {amount} sats"
id="tj6kdX"
values={{
sign: a.direction === "in" ? "+" : "-",
amount: <FormattedNumber value={a.amount / 1e3} />,
}}
/>
</div>
<div className="text-secondary text-sm">
<FormattedMessage
defaultMessage="~{amount}"
id="3QwfJR"
values={{
amount: (
<FormattedNumber
style="currency"
currency="USD"
value={(rates?.ask ?? 0) * a.amount * 1e-11}
/>
),
}}
/>
</div>
</div>
</div>
</div>
<div
className={`nowrap ${(() => {
switch (a.state) {
case WalletInvoiceState.Paid:
return "success";
case WalletInvoiceState.Expired:
return "expired";
case WalletInvoiceState.Failed:
return "failed";
default:
return "pending";
}
})()}`}>
{stateIcon(a.state)}
<FormattedMessage
defaultMessage="{amount} sats"
id="vrTOHJ"
values={{
amount: <FormattedNumber value={a.amount / 1e3} />,
}}
/>
</div>
</div>
))}
</>
);
})}
</div>
);
}
function walletBalance() {
if (wallet instanceof WebLNWallet) return null;
if (!wallet?.canGetBalance()) return;
return (
<small>
<div className="flex items-center gap-2">
<FormattedMessage
defaultMessage="Balance: {amount} sats"
id="VN0+Fz"
defaultMessage="<big>{amount}</big> <small>sats</small>"
id="E5ZIPD"
values={{
big: c => <span className="text-5xl font-bold">{c}</span>,
small: c => <span className="text-secondary text-sm">{c}</span>,
amount: <FormattedNumber value={balance ?? 0} />,
}}
/>
</small>
<AsyncIcon size={20} className="text-secondary cursor-pointer" iconName="closedeye" />
</div>
);
}
function walletInfo() {
if (!wallet?.isReady()) return null;
return (
<>
<div className="card">
<h3>{info?.alias}</h3>
<div className="flex flex-col items-center px-6 py-4 bg-[--gray-superdark] rounded-2xl gap-1">
{walletBalance()}
<div className="text-secondary">
<FormattedMessage
defaultMessage="~{amount}"
id="3QwfJR"
values={{
amount: (
<FormattedNumber style="currency" currency="USD" value={(rates?.ask ?? 0) * (balance ?? 0) * 1e-8} />
),
}}
/>
</div>
</div>
{/*<div className="flex wallet-buttons">
<AsyncButton onClick={createInvoice}>
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
</AsyncButton>
</div>*/}
{walletHistory()}
</>
);
}
return (
<div className="main-content p">
{error && <b className="error">{error}</b>}
<div className="main-content">
{walletList()}
{error && <b className="warning">{error}</b>}
{unlockWallet()}
{walletInfo()}
</div>

View File

@ -8,7 +8,7 @@ import { SnortPubKey } from "@/Const";
import ProfilePreview from "@/Element/User/ProfilePreview";
import useLogin from "@/Hooks/useLogin";
import { UploaderServices } from "@/Upload";
import { bech32ToHex, getRelayName, unwrap } from "@/SnortUtils";
import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/SnortUtils";
import { ZapPoolController, ZapPoolRecipient, ZapPoolRecipientType } from "@/ZapPoolController";
import AsyncButton from "@/Element/Button/AsyncButton";
import { useWallet } from "@/Wallet";
@ -19,17 +19,9 @@ const DataProviders = [
name: "nostr.band",
owner: bech32ToHex("npub1sx9rnd03vs34lp39fvfv5krwlnxpl90f3dzuk8y3cuwutk2gdhdqjz6g8m"),
},
{
name: "semisol.dev",
owner: bech32ToHex("npub12262qa4uhw7u8gdwlgmntqtv7aye8vdcmvszkqwgs0zchel6mz7s6cgrkj"),
},
{
name: "nostr.directory",
owner: bech32ToHex("npub1teawtzxh6y02cnp9jphxm2q8u6xxfx85nguwg6ftuksgjctvavvqnsgq5u"),
},
];
function ZapTarget({ target }: { target: ZapPoolRecipient }) {
export function ZapPoolTarget({ target }: { target: ZapPoolRecipient }) {
if (!ZapPoolController) return;
const login = useLogin();
const profile = useUserProfile(target.pubkey);
@ -156,13 +148,17 @@ export default function ZapPoolPage() {
</p>
<p>
{wallet && (
<AsyncButton onClick={() => ZapPoolController?.payout(wallet)}>
<AsyncButton
onClick={async () => {
trackEvent("ZapPool", { manual: true });
await ZapPoolController?.payout(wallet);
}}>
<FormattedMessage defaultMessage="Payout Now" id="+PzQ9Y" />
</AsyncButton>
)}
</p>
<div>
<ZapTarget
<ZapPoolTarget
target={
zapPool.find(b => b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? {
type: ZapPoolRecipientType.Generic,
@ -179,7 +175,7 @@ export default function ZapPoolPage() {
{relayConnections.map(a => (
<div>
<h4>{getRelayName(a.address)}</h4>
<ZapTarget
<ZapPoolTarget
target={
zapPool.find(b => b.pubkey === a.pubkey && b.type === ZapPoolRecipientType.Relay) ?? {
type: ZapPoolRecipientType.Relay,
@ -197,7 +193,7 @@ export default function ZapPoolPage() {
{UploaderServices.map(a => (
<div>
<h4>{a.name}</h4>
<ZapTarget
<ZapPoolTarget
target={
zapPool.find(b => b.pubkey === a.owner && b.type === ZapPoolRecipientType.FileHost) ?? {
type: ZapPoolRecipientType.FileHost,
@ -215,7 +211,7 @@ export default function ZapPoolPage() {
{DataProviders.map(a => (
<div>
<h4>{a.name}</h4>
<ZapTarget
<ZapPoolTarget
target={
zapPool.find(b => b.pubkey === a.owner && b.type === ZapPoolRecipientType.DataProvider) ?? {
type: ZapPoolRecipientType.DataProvider,

View File

@ -24,7 +24,7 @@ export function Profile() {
name: state.name,
picture,
});
trackEvent("Login:NewAccount");
trackEvent("Login", { newAccount: true });
navigate("/login/sign-up/topics");
} catch (e) {
if (e instanceof Error) {

View File

@ -28,7 +28,7 @@ export function SignIn() {
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;*/
const pubKey = await unwrap(window.nostr).getPublicKey();
LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7);
trackEvent("Login:NIP7");
trackEvent("Login", { type: "NIP7" });
navigate("/");
}
@ -41,8 +41,7 @@ export function SignIn() {
setError("");
try {
await loginHandler.doLogin(key, key => Promise.resolve(new NotEncrypted(key)));
trackEvent("Login:Key");
trackEvent("Login", { type: "Key" });
navigate("/");
} catch (e) {
if (e instanceof Error) {

View File

@ -22,7 +22,7 @@ export default function AccountsPage() {
about: false,
}}
actions={
<div className="f-1">
<div className="flex-1">
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
<FormattedMessage defaultMessage="Switch" id="n1Whvj" />
</button>

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