1
0
forked from Kieran/snort

Compare commits

...

359 Commits

Author SHA1 Message Date
5a7657a95d
feat: diff-sync follows 2024-04-15 22:31:51 +01:00
edf64e4125
feat: safe sync appdata 2024-04-15 13:23:26 +01:00
a089ae2ec6
fix: push-prod 2024-04-12 18:44:14 +01:00
4384c638b9
fix: build order 2024-04-12 18:43:26 +01:00
ecd74fbb12
fix: push-prod 2024-04-12 18:40:26 +01:00
0ab896923e chore: Update translations 2024-04-12 17:32:02 +00:00
3c3e35ad4f
chore: remove debug 2024-04-12 18:31:06 +01:00
e5c8634c59
feat: insertBatchSize 2024-04-12 18:28:50 +01:00
e62bb58362
feat: redirect to login when attempting reaction while logged out
closes #680
2024-04-12 13:53:53 +01:00
64703cf05d
chore: update reactions modal icons
closes #683
2024-04-12 13:39:26 +01:00
6a96afc82c
chore: remove dislike
closes #685
2024-04-12 13:23:22 +01:00
0babc928fa
feat: profile card timming
closes #687
2024-04-12 13:19:35 +01:00
77178bc728 chore: Update translations 2024-04-12 12:07:26 +00:00
b4bcc4d371
fix: plain image link preview size 2024-04-12 13:06:36 +01:00
8d306ce466
feat: render embeds from link preview
closes #693
2024-04-12 12:59:40 +01:00
5292b8880e
closes #709 2024-04-12 12:14:03 +01:00
3e32bc7789
fix: topic buttons
closes #711
2024-04-12 12:12:40 +01:00
19d72722db
feat: improve key export page
closes #712
2024-04-12 12:09:51 +01:00
f601e88b8f
refactor: cleanup ecash nuts embed
closes #718
2024-04-12 11:56:10 +01:00
2f76dd9b10
closes #720 2024-04-12 11:38:40 +01:00
eea2fdfc12 chore: Update translations 2024-04-12 10:34:10 +00:00
e98b7fa17e
fix: remove reposts from grid
closes #722
2024-04-12 11:33:27 +01:00
81827dec96 chore: Update translations 2024-04-12 10:20:32 +00:00
9a88b52b25
fix: hashtag parser
closes #728
2024-04-12 11:19:46 +01:00
5c5c31aadf chore: Update translations 2024-04-12 10:10:29 +00:00
12cf7380a5
feat: Unified NWC icon
closes #732
2024-04-12 11:09:34 +01:00
06e8f1fd73
chore: enable KO translations
closes #735
2024-04-12 11:04:54 +01:00
6394e65400
fix: deps 2024-04-11 14:47:02 +01:00
0720d40cc1
bump 2024-04-11 14:45:22 +01:00
93ea3e8e80
chore: fixes 2024-04-11 14:43:05 +01:00
27a111466a chore: Update translations 2024-04-11 12:30:12 +00:00
8137317bfe
refactor: extract wallet system 2024-04-11 13:27:21 +01:00
d1095847d8 link to subscriptions page from renew task 2024-04-11 15:11:11 +03:00
746a4177cf fix typo 2024-04-09 22:44:50 +03:00
95b160dd04
chore: formatting 2024-04-05 14:13:12 +01:00
a938e466d7
refactor: outbox (inbox query) improvements
feat: sync account tool
2024-04-05 14:13:12 +01:00
a88fda2a22 chore: Update translations 2024-04-04 15:32:59 +00:00
ee31726961
refactor: thread loading improvements 2024-04-04 16:30:46 +01:00
ad2b6dbcf7 chore: Update translations 2024-04-04 10:21:38 +00:00
d0427040b9
Merge remote-tracking branch 'brugeman/fix/nip46-main' 2024-04-04 11:20:17 +01:00
8c9cc7931a
Merge remote-tracking branch 'alex4/avatar-zapsplit-fix' 2024-04-04 11:13:37 +01:00
a3f1393999 chore: Update translations 2024-03-19 12:04:21 +00:00
382f9b6e1b fix: remove COEP 2024-03-19 12:01:27 +00:00
c8b8daeb29 tweak avatars 2024-03-19 12:01:27 +00:00
f20db30062 update iris & nostr/_headers 2024-03-14 10:50:35 +02:00
ead73d06a8 rm localhost relay from iris.json 2024-03-14 10:48:36 +02:00
4f7b9f1b99 Add oauth event handler everywhere, dedup auth_url handling, add requested perms, add since to reply filter 2024-03-08 08:49:36 +03:00
4a7073b231 fix: service-worker 2024-03-07 17:08:51 +00:00
2b98f0fc4a Merge branch 'main' of https://git.v0l.io/Kieran/snort 2024-03-07 16:38:27 +00:00
f937c0edfa chore: remove COOP/COEP 2024-03-07 16:38:22 +00:00
a2fe2255c8 chore: Update translations 2024-03-07 14:12:50 +00:00
583aff433c chore: bump pkg 2024-03-07 13:59:56 +00:00
3153f632c7 fix: multiple tag queries 2024-03-07 13:58:56 +00:00
dbf2346176 fix: main branch 2024-03-07 13:42:29 +00:00
782feedae4 refactor: always fallback to in-memory relay 2024-03-04 16:51:14 +00:00
098251fee3 fix: embed sqlite3.wasm in lib for production builds 2024-03-04 15:25:15 +00:00
99b4d01ff7 fix: remove bad URL constructor 2024-03-04 13:09:09 +00:00
6785ef72e1 Merge branch 'main' of https://git.v0l.io/Kieran/snort 2024-03-04 12:23:23 +00:00
226618ac77 refactor: fix worker-relay for external users 2024-03-04 12:22:48 +00:00
780b3ebe1c chore: Update translations 2024-03-04 08:48:13 +00:00
23584205aa failing thread root load, show current note 2024-03-04 10:44:54 +02:00
1f4427641e chore: Update translations 2024-03-04 08:38:01 +00:00
da099ca214 fix deck clickable articles 2024-03-04 10:29:00 +02:00
2f1f2a5e97 fix note search, rm sort:popular 2024-03-04 10:13:52 +02:00
65552604dc nostr-system fx[1]?.length 2024-02-29 22:56:47 +02:00
1fbff0d04a Deck dir 2024-02-29 21:19:47 +02:00
a0aa86a0b3 delete command on timeout 2024-02-29 12:13:08 +00:00
ed7929db50 worker-relay error timeout 2024-02-29 12:11:49 +00:00
be48c9cc37 fix: duplicate relays 2024-02-29 12:11:21 +00:00
5796892e54 shorten some files 2024-02-29 12:02:27 +02:00
95dc979b8d fix highlighted text in searched notes 2024-02-29 11:14:17 +02:00
04e7d0b54f split NoteContextMenu 2024-02-28 13:01:35 +02:00
bf4e9c9776 split ZapPool 2024-02-28 12:57:26 +02:00
8fef783cf8 split Menu 2024-02-28 12:53:35 +02:00
a9c7edb09d split Thread into smaller files 2024-02-27 17:01:39 +02:00
a5532b23f3 stringify worker error msg 2024-02-27 13:53:26 +02:00
ce5fbf0819 don't reload for you feed on refresh 2024-02-27 12:36:16 +02:00
5e1af603b7 refactor: preload with follow list 2024-02-22 11:46:53 +00:00
670898c016 chore: bump system-react 2024-02-22 11:14:37 +00:00
7558e91d28 feat: automate social graph 2024-02-22 11:12:26 +00:00
3f0bd88db8 chore: bump system-react 2024-02-20 11:32:08 +00:00
8b9acd3109 feat: abstract OutboxModel into RequestRouter 2024-02-20 11:28:02 +00:00
e5f8bebb53 iframes broken on some browsers, show link url 2024-02-16 10:44:08 +02:00
86906682f9 always scroll to top on route change, except on back nav 2024-02-16 10:28:07 +02:00
73753c2764 chore: Update translations 2024-02-15 16:52:37 +00:00
1be9254221 bump system-react 2024-02-15 16:45:09 +00:00
eedbe90197 fix: oAuth bugs 2024-02-15 15:35:24 +00:00
c19e7ad20c feat: NIP-46 oAuth sign up 2024-02-15 11:40:05 +00:00
0d9d5a0a4c feat: nip46 oAuth login 2024-02-15 11:28:09 +00:00
512307f42d fix feed glitch by memoizing NoteText 2024-02-12 12:54:29 +02:00
96947fad2e chore: Update translations 2024-02-12 10:46:03 +00:00
e2164800a9 scroll to top on opening notifications & thread pages 2024-02-12 12:36:34 +02:00
ff5d2ee32d chore: Update translations 2024-02-12 10:17:34 +00:00
48ba5cecbd chore: Update translations 2024-02-12 10:14:53 +00:00
6022b6007a chore: Update translations 2024-02-12 10:13:20 +00:00
b0f230e70d chore: Update translations 2024-02-12 10:11:56 +00:00
a66361049d chore: Update translations 2024-02-12 10:08:43 +00:00
875996347b chore: Update translations 2024-02-12 10:07:12 +00:00
c43d0c7a86 chore: Update translations 2024-02-12 10:06:01 +00:00
ac4028f191 chore: Update translations 2024-02-12 10:04:39 +00:00
18c366502c defaultPreferences.defaultRootTab 2024-02-12 11:57:30 +02:00
3e43300077 chore: Update translations 2024-02-12 08:51:27 +00:00
2d74f55e06 quick signup (iris) 2024-02-12 10:44:00 +02:00
248b35d3ca fix: ensure __migration table exists 2024-02-07 11:06:54 +02:00
2f0b4f8d96 handle invalid tags 2024-02-07 11:06:54 +02:00
d1129b3b15 chore: Update translations 2024-02-07 08:49:58 +00:00
b07961802c reactions tab 2024-02-07 10:36:17 +02:00
df16384f07 seen_at db index, migrations, img crossorigin, errorpage clear opfs 2024-02-07 09:22:28 +02:00
edbfa02c52 ask relays for some ForYou feed events 2024-02-06 11:05:27 +02:00
0a05cf864c note layout fix 2024-02-05 23:31:26 +02:00
47e0c5a8cc change note seen timeout 2000 -> 1000 2024-02-05 12:39:28 +02:00
280a7eaac2 chore: Update translations 2024-02-05 10:01:12 +00:00
aefe8a8210 LRUCache ParsedZaps, import from system dir in worker 2024-02-05 11:50:05 +02:00
a97e895cb8 fix rendering glitch 2024-02-05 11:14:05 +02:00
7ceab04cbc set event seen_at times, sort by seen_at in ForYouFeed 2024-02-05 11:06:46 +02:00
5bc3c10d36 move for you feed creation to worker 2024-02-05 11:06:46 +02:00
351a249a32 migrations file, add seen_at column to events 2024-02-05 11:06:46 +02:00
e0e0a857b0
Fix avatar CSS on zap splits 2024-02-04 16:23:57 -06:00
c5e534a730 chore: Update translations 2024-02-04 05:56:15 +00:00
2099eddebc show reply count in feed 2024-02-04 07:50:45 +02:00
d42d26fc20 tauri config & readme 2024-02-04 07:28:03 +02:00
006cad49bb chore: Update translations 2024-02-03 18:39:27 +00:00
e652cc7703 ForYou 2024-02-03 20:34:19 +02:00
c23856daf4 NavSidebar select-none 2024-02-03 20:34:19 +02:00
3c017f89be ForYou: favor recentness in most notes 2024-02-03 20:34:19 +02:00
e2ab1b4e3f chore: Update translations 2024-02-03 15:15:03 +00:00
d0bc8df6a1 ForYou: events from favorite authors, debug logging 2024-02-03 17:09:00 +02:00
e746109f5c mix some recent events into the for you feed 2024-02-03 17:09:00 +02:00
0716fb4752 chore: Update translations 2024-02-03 13:01:42 +00:00
2ab7e63e55 common like counts in scoring 2024-02-03 14:55:18 +02:00
4dff677809 global tab fix, root tab routing 2024-02-03 13:38:03 +02:00
ce2218bc93 exclude your own events from ForYou 2024-02-03 12:43:07 +02:00
f4ab402e34 for you 2024-02-03 00:32:45 +02:00
19a396c7d3 better feed algo 2024-02-03 00:28:54 +02:00
35ec58377c for you feed etc 2024-02-02 23:20:34 +02:00
2ab2001014 add times to notifications 2024-02-02 16:06:47 +02:00
5ea195a341 show (disabled) repost btn even when not logged in 2024-02-02 15:46:34 +02:00
523fd1a0ba notifs style 2024-02-02 15:46:34 +02:00
1fd37a42d2 orderDescending not needed with SortedMap 2024-02-02 15:46:34 +02:00
cdd814cf73 chore: Update translations 2024-02-02 12:03:21 +00:00
8c9381fc6c AutoLoadMore 2024-02-02 13:57:17 +02:00
6feac60a4a chore: Update translations 2024-02-02 10:23:49 +00:00
f3272bed57 move loadMore button to TimelineRenderer 2024-02-02 12:17:14 +02:00
3fa4dbf100 img cursor-pointer 2024-02-02 11:04:15 +02:00
a9c8fd9ba5 img grid load more btn 2024-02-02 10:47:49 +02:00
4b335faa36 debounce profile search from relays 2024-02-02 09:21:26 +02:00
cfb9c4adfd send REQ with ids_only unchanged 2024-02-02 08:41:55 +02:00
6bc5387afc link to profile from chat 2024-02-01 10:35:45 +02:00
cd5cc07857 chore: Update translations 2024-02-01 08:17:27 +00:00
13a773a1ad check alreadyHaves from system.cacheRelay 2024-02-01 10:11:12 +02:00
eb9f23b73b add https:// to youtube urls in _headers 2024-01-31 21:16:10 +02:00
e88cc64cb2 add iris.json alby oauth config 2024-01-31 15:22:44 +02:00
0de93a0a53 no ids_only in negentropy msgs, GET in rsp to HAVE 2024-01-31 15:15:22 +02:00
579589f635 nip-114: filter.ids_only, HAVE message 2024-01-31 13:55:35 +02:00
7c1f2c539f Merge pull request 'Donate page update' (#730) from trycatchkamal/snort:main into main
Reviewed-on: Kieran/snort#730
2024-01-31 09:57:18 +00:00
ce4d99dc88 updated contributors list 2024-01-31 01:50:59 +00:00
74d6cc9932
fix: lockfile 2024-01-30 23:02:39 +00:00
07510d92ca
refactor: include relays in kind3 2024-01-30 22:38:23 +00:00
ad8d0af976
chore: bump pacakges 2024-01-30 22:04:29 +00:00
2ef1b591e2 chore: Update translations 2024-01-30 20:38:48 +00:00
a7c0cf7397
chore: add worker-relay readme 2024-01-30 20:32:43 +00:00
a14a5fa96b
chore: publish worker-relay 2024-01-30 20:09:38 +00:00
8c19f4de68
chore: Remove here map 2024-01-30 20:09:38 +00:00
5fc844b911 chore: Update translations 2024-01-29 14:46:38 +00:00
14c8c9a080
feat: render NIP-107 data 2024-01-29 14:38:31 +00:00
470e5b31ce update preview api url in sw 2024-01-27 10:26:27 +02:00
82d5b9fb64 note translation sw & lru cache 2024-01-27 10:20:19 +02:00
dc99d2a653 SearchBox: ask relays 2024-01-26 20:36:05 +02:00
e343c5cb9b add headers to iris 2024-01-26 19:58:48 +02:00
b07f9abe16
fix: infinite scroll 2024-01-26 17:22:02 +00:00
404a07f45a
fix: docker build 2024-01-26 16:55:30 +00:00
3fb7b7adc4
fix: ci buildx push 2024-01-26 15:42:00 +00:00
28f7133236
fix: ci add builx builder 2024-01-26 15:37:51 +00:00
c18f8eddbb
fix: ci build command 2024-01-26 15:29:40 +00:00
d55c9ad122
fix: ci try node:current img 2024-01-26 15:23:27 +00:00
fea7a9a63a
chore: update vite 2024-01-26 15:17:29 +00:00
5d9b306d41
fix: drone build 2024-01-26 15:05:14 +00:00
8061410333
fix: NWC wrong method names 2024-01-26 13:37:49 +00:00
52b52deb72
chore: remove unused 2024-01-26 13:34:31 +00:00
68583e24b8
chore: always show deck link 2024-01-26 13:30:48 +00:00
88766c6c08
chore: formatting 2024-01-26 13:29:59 +00:00
c8c0cc2ac5
fix: dm chat list hidden on mobile 2024-01-26 13:29:15 +00:00
3355822bcd
chore: release notes 2024-01-26 13:14:18 +00:00
0fd8cf3f49
fix: profile page notes 2024-01-26 12:17:47 +00:00
1aaee2a2cb
chore: cleanup 2024-01-26 11:47:25 +00:00
dae96109b8 chore: Update translations 2024-01-26 11:26:24 +00:00
f9a0516718
fix: null relay tag in event reply 2024-01-26 11:24:58 +00:00
d3e6ddc64c
chore: formatting 2024-01-26 11:18:23 +00:00
7a6637a86f
feat: query save/restore 2024-01-26 11:18:23 +00:00
22863a289d
fix: notification avatar overflow
refactor: sort notification group avatar by WoT score
2024-01-26 11:18:23 +00:00
f10ad6dd53
fix: profile mentions 2024-01-26 11:18:22 +00:00
d3873ea281
fix: preload 2024-01-26 11:18:22 +00:00
4f4649da2c
feat: price chart 2024-01-26 11:18:22 +00:00
9a220fafd5
refactor: revert LocalSearch 2024-01-26 11:18:22 +00:00
e72f779ab7 chore: Update translations 2024-01-25 16:24:41 +00:00
9a3207bfa3
feat: add fallback sync 2024-01-25 16:02:16 +00:00
d7460651c8
feat: negentropy 2024-01-25 15:21:42 +00:00
9a0bbb8b74
refactor: hashtags timeline weaver to worker relay 2024-01-24 11:43:51 +00:00
f9d08267a6 chore: Update translations 2024-01-23 22:24:52 +00:00
e9d9bf34d8
refactor: migrate chats to relay worker cache 2024-01-23 22:16:53 +00:00
c968fa43a6 chore: Update translations 2024-01-23 15:47:12 +00:00
982f4df0a3
chore: formatting 2024-01-23 15:36:48 +00:00
5cea096067
feat: @snort/system CacheRelay 2024-01-23 15:35:28 +00:00
d6c578fafc
fix: config 2024-01-22 17:21:46 +00:00
e9cf2e141b
chore: update _headers 2024-01-22 16:58:15 +00:00
02ec637266
chore: remove unused 2024-01-22 16:48:48 +00:00
e7f9b5e2ea
refactor: improve whitelabel config 2024-01-22 16:41:50 +00:00
4aa00405ee chore: Update translations 2024-01-22 15:06:57 +00:00
65a96eb77b
refactor: more config options for generic config 2024-01-22 15:00:17 +00:00
6fd02cffbb
feat: generic nostr.com client config 2024-01-22 13:52:18 +00:00
ef8a5c29bf
chore: update hostname 2024-01-22 12:58:15 +00:00
381a849a11 chore: Update translations 2024-01-22 11:38:58 +00:00
45fbd06bff
feat: play stream with zap.stream embed 2024-01-22 11:33:38 +00:00
d1ebd49d56 chore: Update translations 2024-01-20 11:09:15 +00:00
6354472d05 "view as user" button in profile 2024-01-20 13:00:47 +02:00
6a1a990e57 chore: Update translations 2024-01-20 00:00:44 +00:00
d1972542b7
fix: iframe credentialless 2024-01-19 23:59:01 +00:00
9ceb3c705f chore: Update translations 2024-01-19 23:23:53 +00:00
9be57a6e84
configure sqlite relay 2024-01-19 23:16:45 +00:00
6722ad5f8e lrucache fix 2024-01-20 00:42:31 +02:00
29cb9a61b4 lrucache fix 2024-01-20 00:26:02 +02:00
a66f7f5fd8 chore: Update translations 2024-01-19 22:18:19 +00:00
2033137ae2
refactor: return fuzzy profile search 2024-01-19 22:16:48 +00:00
3d2f11f206 correct LRUCache size param 2024-01-20 00:07:12 +02:00
08bfd38563 add profile events from db to fuzzy search 2024-01-20 00:01:13 +02:00
8fb127b347 initial state from LRUCache 2024-01-20 00:00:50 +02:00
5ddc5ee8df
fix: delete from search 2024-01-19 20:05:46 +00:00
53c8ccbd0f
feat: local releay search 2024-01-19 19:56:14 +00:00
9654f70c22 chore: Update translations 2024-01-19 13:40:42 +00:00
bf66f273e3
chore: include sourcemap 2024-01-19 13:38:37 +00:00
da6fa415dd
fix: use command queue for batch event write 2024-01-19 13:25:14 +00:00
72b98a4ab5
fix: set coop/coep in function mw 2024-01-19 13:12:39 +00:00
7e88d96ddb
fix: COEP header 2024-01-19 13:01:38 +00:00
cb0b75c652
feat: add fallback memory relay 2024-01-19 11:56:18 +00:00
8e33d10319 chore: Update translations 2024-01-19 10:37:24 +00:00
0b307ae691
refactor: delay batches until req's finish 2024-01-19 10:26:37 +00:00
2b1cf34424
refactor: move inMemoryDb hook 2024-01-19 10:09:36 +00:00
0307bacd30
fixes 2024-01-18 22:47:48 +00:00
aa9d5d72be chore: Update translations 2024-01-18 22:44:43 +00:00
ba3e901e9b
refactor: fix followgraph / add indexes 2024-01-18 22:39:27 +00:00
6eef8c7fef
feat: profile cache worker relay 2024-01-18 22:39:27 +00:00
084558b3e7 chore: Update translations 2024-01-18 21:17:38 +00:00
32a6d56cf5
feat: use worker relay for events cache 2024-01-18 21:13:32 +00:00
c2f78dad1e chore: Update translations 2024-01-18 16:08:18 +00:00
0239db393f
chore: formatting 2024-01-18 16:01:19 +00:00
f147edd03c
fix: relay-worker insert replacable events 2024-01-18 16:01:19 +00:00
e3f8d48ddb logging 2024-01-18 16:40:01 +02:00
d019544053 chore: Update translations 2024-01-18 14:19:35 +00:00
712848a129
feat: skip internal query store 2024-01-18 14:06:13 +00:00
3ff651ec37
feat: request builder option fillStore 2024-01-18 13:06:52 +00:00
f20cd8a119
chore: remove unused 2024-01-18 12:40:59 +00:00
2d4c323cf7
feat: emit updates from relay-worker 2024-01-18 12:28:16 +00:00
6d8c0325e4
feat: process worker messages in queue 2024-01-18 12:27:05 +00:00
2ea516e636 useSubscribe, handle emitted requests in sqlite 2024-01-18 13:47:29 +02:00
b7e61ebde5
fix: remove semicolon 2024-01-17 23:01:24 +00:00
2e27c1b41a
chore: fix strings 2024-01-17 23:00:53 +00:00
34b2d9b743 chore: Update translations 2024-01-17 21:17:55 +00:00
d990e9ffad
chore: move headers file 2024-01-17 21:10:07 +00:00
62ff3df30d chore: Update translations 2024-01-17 16:39:35 +00:00
f043a9ee96
chore: add extra headers 2024-01-17 16:34:44 +00:00
adb9fe5c2e
chore: formatting 2024-01-17 15:48:30 +00:00
aa58ec4185
feat: upgrade caches to worker 2024-01-17 15:47:01 +00:00
3c808688f8 fix mobile footer sized padding 2024-01-16 20:08:27 +02:00
aa430de168 rm nip 113 2024-01-16 20:08:07 +02:00
fe46959424 chore: Update translations 2024-01-15 19:59:25 +00:00
9ae097907a
fix: config preferences 2024-01-15 19:49:38 +00:00
a7ac246a43
feat: worker-relay pkg 2024-01-15 16:57:20 +00:00
6899e46622
chore: bump pkgs 2024-01-15 12:04:59 +00:00
e45d6ffa52 chore: Update translations 2024-01-15 11:34:15 +00:00
3b6e194ded chore: Update translations 2024-01-15 11:29:06 +00:00
21d7df0eac
refactor: use link preview from nostr-services api 2024-01-15 11:16:41 +00:00
ad79091356 chore: Update translations 2024-01-15 08:25:32 +00:00
148acc764c add event ids from q.feed.takeSnapshot() to filter.not 2024-01-15 10:21:16 +02:00
1f90b2fe90 add some search relays 2024-01-15 01:57:27 +02:00
bc22ee7d56
fix: wallet pay plain invoice 2024-01-14 17:01:10 +00:00
eb2601448c fix global tab 2024-01-14 14:58:52 +02:00
773db5dea6 no walletbalance in narrow sidebar 2024-01-14 12:12:36 +02:00
4fe2554d9d enable subscriptions on iris 2024-01-14 12:10:25 +02:00
d4233a818e lint 2024-01-13 23:06:25 +02:00
13b7a16dc7 system.HandleEvent -> querymanager -> matching queries 2024-01-13 22:45:30 +02:00
736c2577db chore: Update translations 2024-01-13 18:54:14 +00:00
7935d3d86a memoize Timeline subject 2024-01-13 20:45:17 +02:00
57d4d6b2c6 chore: Update translations 2024-01-13 13:33:53 +00:00
47fc8e1414 fix polls 2024-01-13 15:25:21 +02:00
a98bbd65b5 fix profile notes tab 2024-01-12 17:51:23 +02:00
ef673c2a05 tab selector vs content naming, refactoring 2024-01-12 17:33:02 +02:00
ffa4a192f6 split ProfilePage 2024-01-12 17:02:44 +02:00
5ab39aafe8 chore: Update translations 2024-01-12 13:55:39 +00:00
43ed484bcc
chore: generate docs 2024-01-12 13:53:42 +00:00
2cadab20b4 fix feed padding bottom 2024-01-12 15:37:15 +02:00
1cef1e0187 light theme 2024-01-12 15:28:16 +02:00
e0c4b64865 light theme notecreator style 2024-01-12 15:22:14 +02:00
a9405388c0 light theme primary button bg 2024-01-12 15:13:46 +02:00
775ee6423f chore: Update translations 2024-01-12 12:40:53 +00:00
1aaff4f553 extend default preferences from config 2024-01-12 14:37:13 +02:00
6657161a32 open zaps tab 2024-01-12 14:06:13 +02:00
7ee210da16 open reactions modal from note footer zappers list 2024-01-12 14:01:26 +02:00
26e12d1c0b chore: Update translations 2024-01-11 23:08:03 +00:00
703da5389a add +n after zappers 2024-01-12 01:04:41 +02:00
ad2029d1d7 no hover bg color in notecreator 2024-01-12 00:09:44 +02:00
20d3fdaa6e chore: Update translations 2024-01-11 21:51:15 +00:00
69d6dfd5d6 rename SendSats -> ZapModal 2024-01-11 23:47:25 +02:00
c8dae9fae6 split SendSats 2024-01-11 23:33:24 +02:00
649bab228b split notefooter into smaller components, CONFIG.showPowIcon 2024-01-11 23:04:43 +02:00
edca8a9636 chore: Update translations 2024-01-11 14:40:01 +00:00
9bdf60a24f extract zap button component from note footer 2024-01-11 16:32:52 +02:00
dffb33bfda notefooter icons 2024-01-11 15:46:13 +02:00
de6685ade3 show zapper avatars on the same notefooter row 2024-01-11 15:46:13 +02:00
536f8ddc5b
refactor: adjust response headers 2024-01-11 11:18:26 +00:00
d45d601712 chore: Update translations 2024-01-11 10:00:36 +00:00
c75ab861b5
fix: muted note styles
closes #721
2024-01-11 09:54:11 +00:00
3fe6ed952c
Merge remote-tracking branch 'kamal/enhancements/snort#702' 2024-01-11 09:38:50 +00:00
8090bb1718
chore: add jeremy 2024-01-11 09:32:43 +00:00
501ad41fff chore: Update translations 2024-01-11 09:24:36 +00:00
ee01623bf1 memoize proxyimg 2024-01-11 11:19:16 +02:00
eb9cf7f361 fuzzy search on search page 2024-01-11 10:54:27 +02:00
45f66fd139 gallery img sizing 2024-01-11 09:21:30 +02:00
8043f1034f chore: Update translations 2024-01-11 07:14:00 +00:00
76d3c78c0a replied note auto height, resized feed imgs 2024-01-11 08:56:23 +02:00
21e1202b97
refactor: pass nip5 to api 2024-01-10 22:44:37 +00:00
ab8121c4b2 fix broken proxyimg placeholder 2024-01-10 23:26:51 +02:00
d3c9fef9af smaller avatar cache, exclude gifs from img cache 2024-01-10 23:01:11 +02:00
8c8a7c7e88 avatar image sw cache 2024-01-10 22:49:36 +02:00
cb233f4ccb cacheableresponse plugin fixes img sw cache 2024-01-10 22:16:04 +02:00
326ce2ba68
fix: opengraph middleware 2024-01-10 20:07:06 +00:00
8cca297d6d eslint: react-refresh/only-export-components error 2024-01-10 21:13:08 +02:00
a3fc25f64c show media in replied note 2024-01-10 21:00:39 +02:00
a1f61e2d13 limit replying-to note size 2024-01-10 20:54:48 +02:00
51758eaf5e separator border in replying to note 2024-01-10 20:32:51 +02:00
5baffd00b9 fix the rest of warnings 2024-01-10 20:21:19 +02:00
e6a42db658 fix more warnings, store transformed text in LRUCache of 1000 2024-01-10 19:54:01 +02:00
8e37e0fbed fix some warnings 2024-01-10 19:31:37 +02:00
7220435d15 chore: Update translations 2024-01-10 16:50:28 +00:00
53488a9c59 memoize imagegriditem 2024-01-10 18:48:36 +02:00
1278867ad0 search page routing, resized grid images 2024-01-10 18:25:24 +02:00
be4ee620ad chore: Update translations 2024-01-10 16:18:56 +00:00
87386c9950
feat: use new opengraph functions 2024-01-10 16:16:33 +00:00
baf6cc34ee reorganize, fix some fast refresh warnings 2024-01-10 18:03:52 +02:00
071eed0d8c search page params 2024-01-10 17:44:54 +02:00
6f9a1fd706 middle column pb-[50vh] 2024-01-10 17:34:12 +02:00
90342325fd eslint warning fixes 2024-01-10 17:29:24 +02:00
b686b8ff26 useBadgesFeed fix 2024-01-10 17:23:18 +02:00
835385836f chore: Update translations 2024-01-10 14:51:13 +00:00
93608f817f show zap pool notification only if wallet is ready 2024-01-10 16:36:37 +02:00
9e2582ac81 show only 1 task at a time 2024-01-10 16:36:30 +02:00
35d7ec4685 followed by foafs 2024-01-10 16:36:23 +02:00
062212f311 useHistoryState for TimelineFollows latest time 2024-01-10 16:36:16 +02:00
52adf6fb1f store link previews in lrucache to prevent layout shift 2024-01-10 16:36:09 +02:00
90b15ee668 error page, sw 2024-01-10 16:36:02 +02:00
7be4b0bd18 rm custom sw registration, vite-plugin-pwa handles 2024-01-10 16:35:54 +02:00
b8cdb4bf58 chore: Update translations 2024-01-10 10:10:18 +00:00
cf6b431d73 sw fix 2024-01-10 12:01:45 +02:00
91f0afdb89
fix: build 2024-01-09 18:55:42 +00:00
8a5a089b4d
Merge remote-tracking branch 'origin/main' 2024-01-09 16:40:51 +00:00
80fa5a132b
refactor: reactions grouping and other fixes 2024-01-09 16:40:31 +00:00
1a4a76d7fa chore: Update translations 2024-01-09 13:25:01 +00:00
4455651d47
refactor: Query emits Filters 2024-01-09 12:54:07 +00:00
3c97d73536 remove custom style and use tailwind 2023-12-18 08:05:04 +00:00
77925e6647 hiddennote style changes, new preference to hide muted notes 2023-12-18 08:05:02 +00:00
483 changed files with 18551 additions and 14399 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
**/node_modules
**/.pnp.*
**/.yarn/*
!**/.yarn/patches
!**/.yarn/plugins
!**/.yarn/releases
!**/.yarn/sdks
!**/.yarn/versions
**/.idea
**/target

View File

@ -17,17 +17,19 @@ steps:
commands:
- git fetch --tags
- name: Build site
image: node:current-bullseye
image: node:current
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-docker
NODE_CONFIG_ENV: default
commands:
- apt update && apt install -y git
- yarn install
- yarn build
- name: build docker image
image: r.j3ss.co/img
image: docker
privileged: true
volumes:
- name: cache
@ -36,9 +38,11 @@ steps:
TOKEN:
from_secret: docker_hub
commands:
- img login -u voidic -p $TOKEN
- img build -t voidic/snort:latest --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:latest
- dockerd &
- docker login -u voidic -p $TOKEN
- docker buildx create --name mybuilder --bootstrap --use
- docker buildx build -t voidic/snort:latest --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
- kill $(cat /var/run/docker.pid)
volumes:
- name: cache
claim:
@ -53,12 +57,13 @@ metadata:
namespace: git
steps:
- name: Test/Lint
image: node:current-bullseye
image: node:current
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-test
NODE_CONFIG_ENV: default
commands:
- yarn install
- yarn build
@ -84,12 +89,13 @@ metadata:
namespace: git
steps:
- name: Push/Pull translations
image: node:current-bullseye
image: node:current
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-translations
NODE_CONFIG_ENV: default
TOKEN:
from_secret: gitea
CTOKEN:
@ -129,17 +135,19 @@ steps:
commands:
- git fetch --tags
- name: Build site
image: node:current-bullseye
image: node:current
volumes:
- name: cache
path: /cache
environment:
YARN_CACHE_FOLDER: /cache/.yarn-docker-release
YARN_CACHE_FOLDER: /cache/.yarn-docker-
NODE_CONFIG_ENV: default
commands:
- apt update && apt install -y git
- yarn install
- yarn build
- name: build docker image
image: r.j3ss.co/img
image: docker
privileged: true
volumes:
- name: cache
@ -148,9 +156,11 @@ steps:
TOKEN:
from_secret: docker_hub
commands:
- img login -u voidic -p $TOKEN
- img build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:$DRONE_TAG
- dockerd &
- docker login -u voidic -p $TOKEN
- docker buildx create --name mybuilder --bootstrap --use
- docker buildx build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
- kill $(cat /var/run/docker.pid)
volumes:
- name: cache
claim:

3
.gitignore vendored
View File

@ -11,4 +11,5 @@ dist/
*.tgz
*.log
.DS_Store
.pnp*
.pnp*
docs/

File diff suppressed because one or more lines are too long

893
.yarn/releases/yarn-4.1.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@ -1 +1,9 @@
yarnPath: .yarn/releases/yarn-3.6.3.cjs
compressionLevel: mixed
enableGlobalCache: false
npmScopes:
here:
npmRegistryServer: "https://repo.platform.here.com/artifactory/api/npm/maps-api-for-javascript/"
yarnPath: .yarn/releases/yarn-4.1.1.cjs

View File

@ -1,12 +1,12 @@
FROM node:19 as build
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml .
COPY .yarn .yarn
COPY packages packages
RUN yarn --network-timeout 1000000
RUN yarn build
FROM node:current as build
WORKDIR /src
RUN apt update \
&& apt install -y --no-install-recommends git \
&& git clone --single-branch -b main https://git.v0l.io/Kieran/snort \
&& cd snort \
&& yarn --network-timeout 1000000 \
&& yarn build
FROM nginxinc/nginx-unprivileged:mainline-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/packages/app/build /usr/share/nginx/html
COPY --from=build /src/snort/packages/app/build /usr/share/nginx/html

View File

@ -65,6 +65,19 @@ To build the application and system packages, use
$ yarn build
```
Tauri desktop application:
```
# install dependencies
yarn
# develop
yarn tauri dev
# build
yarn tauri build
```
### Translations
[![Crowdin](https://badges.crowdin.net/snort/localized.svg)](https://crowdin.com/project/snort)

View File

@ -3,6 +3,9 @@ server {
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com";
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
location / {
try_files $uri $uri/ /index.html =404;

50
functions/_middleware.ts Normal file
View File

@ -0,0 +1,50 @@
interface Env {}
const HOST = "snort.social";
export const onRequest: PagesFunction<Env> = async context => {
const u = new URL(context.request.url);
const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"];
const isEntityPath = prefixes.some(
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`),
);
const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i);
const next = await context.next();
if (u.pathname != "/" && (isEntityPath || nostrAddress)) {
//console.log("Handeling path: ", u.pathname, isEntityPath, nostrAddress[1]);
try {
let id = u.pathname.split("/").at(-1);
if (!isEntityPath && nostrAddress) {
id = `${id}@${HOST}`;
}
const fetchApi = `https://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
`https://${HOST}/%s`,
)}`;
console.log("Fetching tags from: ", fetchApi);
const rsp = await fetch(fetchApi, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": `SnortFunctions/1.0 (https://${HOST})`,
"content-type": "text/html",
accept: "text/html",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
...Object.fromEntries(rsp.headers.entries()),
"cache-control": "public, max-age=60",
},
});
}
}
} catch (e) {
console.error(e);
}
}
return next;
};

View File

@ -1,30 +0,0 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/e/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

View File

@ -1,30 +0,0 @@
interface Env {}
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
try {
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/p/${id}`, {
method: "POST",
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html",
},
});
}
}
} catch {
// ignore
}
return next;
};

View File

@ -4,24 +4,29 @@
"packages/*"
],
"scripts": {
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
"build": "yarn workspace @snort/shared build && yarn workspace @snort/worker-relay build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/wallet build && yarn workspace @snort/app build",
"start": "yarn build && yarn workspace @snort/app start",
"test": "yarn build && yarn workspace @snort/app test && yarn workspace @snort/system test",
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write .",
"push-prod": "git checkout snort-prod && git merge --ff-only main && git push && git checkout main"
"push-prod": "git switch snort-prod && git merge --ff-only main && git push && git checkout main",
"docs": "typedoc --entryPointStrategy packages ./packages/* --exclude ./packages/app --exclude ./packages/webrtc-server --name snort.social"
},
"prettier": {
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid",
"trailingComma": "all"
"trailingComma": "all",
"endOfLine": "lf"
},
"packageManager": "yarn@3.6.3",
"packageManager": "yarn@4.1.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@tauri-apps/cli": "^1.2.3",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"devDependencies": {
"typedoc": "^0.25.7"
}
}

View File

@ -16,11 +16,20 @@ module.exports = {
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "warn",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {

View File

@ -1,3 +1,47 @@
# v0.2.0
`+16,990,-9,649`
## Added
- Check notification settings page
- New settings page layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Community Leaders / Invite system - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Settings->Tools pages (Check follows relay health etc) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- New wallet pages design - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Alby OAuth wallet connection - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Cashu wallet support (WIP) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Followed by friends feed page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Fuzzysearch profiles everywhere - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Worker Relay package `@snort/worker-relay` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Replaces all previous caching objects, all caches are handled inside `@snort/system` via worker relay
- "View as user" button - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Play live streams directly in feed with embed iframe - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Negentropy v1 support - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Changed
- Hidden note styles & preferences - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
- Keybinds for grid modal navigation - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache trending sections in browser - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache images / nostr.json in service worker - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Add dimensions to `imeta` tag for void.cat uploads - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Check event sigs in `@snort/system-wasm` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
- Primary color scheme - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Note creator styles (removed hashtags input) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Cache link preview results in memory - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show only 1 task at a time in task list - nostr:
- Render media in reply to note creator - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Show top zappers inline with footer icons on notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Add more search relays - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Moved link previews and opengraph tagging to https://nostr.api.v0l.io - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
## Fixed
- Iris account error mesage - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Light theme color fixes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- Notifications page overflow - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
# v0.1.24
`+11,573,-3,010`
@ -583,7 +627,7 @@ https://git.v0l.io/Kieran/snort/compare/v0.1.9...v0.1.10
- Fix event mention bug by @SamSamskies in https://github.com/v0l/snort/pull/421
- fix NaN when parsing empty string by @SamSamskies in https://github.com/v0l/snort/pull/422
- NIP06 support by @w3irdrobot in https://github.com/v0l/snort/pull/425
- Added key attr to Tabs to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
- Added key attr to TabSelectors to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/426
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/436
- Update Wavlake embed URL, add support for album & artist links by @blastshielddown in https://github.com/v0l/snort/pull/439

View File

@ -1,2 +0,0 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

View File

@ -4,8 +4,7 @@
"appTitle": "Snort - Nostr",
"hostname": "snort.social",
"nip05Domain": "snort.social",
"favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png",
"icon": "/nostrich_512.png",
"navLogo": null,
"publicDir": "public/snort",
"httpCache": "",
@ -17,12 +16,18 @@
"deck": true,
"zapPool": true,
"notificationGraph": true,
"communityLeaders": true
"communityLeaders": true,
"nostrAddress": true,
"pushNotifications": true
},
"signUp": {
"moderation": true,
"quickStart": false,
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
@ -33,15 +38,21 @@
"noteCreatorToast": false,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false }
"wss://relay.damus.io/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
}
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
]
}

View File

@ -4,8 +4,7 @@
"appTitle": "iris",
"hostname": "iris.to",
"nip05Domain": "iris.to",
"favicon": "public/iris/favicon.ico",
"appleTouchIconUrl": "/img/apple-touch-icon.png",
"icon": "/img/icon128.png",
"navLogo": "/img/icon128.png",
"publicDir": "public/iris",
"httpCache": "",
@ -13,14 +12,18 @@
"defaultZapPoolFee": 0.5,
"features": {
"analytics": true,
"subscriptions": false,
"subscriptions": true,
"deck": true,
"zapPool": true,
"notificationGraph": false,
"communityLeaders": true
},
"defaultPreferences": {
"hideMutedNotes": true,
"defaultRootTab": "for-you"
},
"signUp": {
"moderation": false,
"quickStart": true,
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
},
"media": {
@ -34,12 +37,20 @@
"hideFromNavbar": [],
"eventLinkPrefix": "note",
"profileLinkPrefix": "npub",
"showPowIcon": false,
"defaultRelays": {
"ws://localhost:7777": { "read": true, "write": true },
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false },
"wss://relay.nostr.band/": { "read": true, "write": true },
"wss://relay.damus.io/": { "read": true, "write": true }
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
],
"alby": {
"clientId": "5rYcHDrlDb",
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"
}
}

View File

@ -0,0 +1,50 @@
{
"appName": "Nostr",
"appNameCapitalized": "Nostr",
"appTitle": "Nostr",
"hostname": "nostr.com",
"nip05Domain": "nostr.com",
"icon": "/nostr.jpg",
"navLogo": null,
"publicDir": "public/nostr",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": false,
"subscriptions": false,
"deck": false,
"zapPool": false,
"notificationGraph": true,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": false
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": ["/graph"],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://eden.nostr.land/": { "read": true, "write": false },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": null,
"chatChannels": null
}

View File

@ -1,4 +1,5 @@
/// <reference types="@webbtc/webln-types" />
/// <reference types="vite/client" />
declare module "*.jpg" {
const value: unknown;
@ -46,8 +47,7 @@ declare const CONFIG: {
appTitle: string;
hostname: string;
nip05Domain: string;
favicon: string;
appleTouchIconUrl: string;
icon: string;
navLogo: string | null;
httpCache: string;
animalNamePlaceholders: boolean;
@ -59,12 +59,15 @@ declare const CONFIG: {
zapPool: boolean;
notificationGraph: boolean;
communityLeaders: boolean;
nostrAddress: boolean;
pushNotifications: boolean;
};
defaultPreferences: {
checkSigs: boolean;
hideMutedNotes: boolean;
defaultRootTab: "following" | "for-you";
};
signUp: {
moderation: boolean;
quickStart: boolean;
defaultFollows: Array<string>;
};
media: {
@ -89,18 +92,20 @@ declare const CONFIG: {
eventLinkPrefix: NostrPrefix;
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
showPowIcon: boolean;
// Alby wallet oAuth config
alby?: {
clientId: string;
clientSecret: string;
};
};
/**
* Single relay (Debug)
*/
declare const SINGLE_RELAY: string | undefined;
// public chat channels for site
chatChannels?: Array<{
type: "nip28" | "telegram";
value: string;
}>;
};
/**
* Build git hash

View File

@ -11,10 +11,12 @@
name="keywords"
content="nostr snort fast decentralized social media censorship resistant open source software" />
<link rel="preconnect" href="https://imgproxy.snort.social" />
<link rel="apple-touch-icon" href="" />
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.png" />
<title></title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>

View File

@ -1,9 +1,9 @@
{
"name": "@snort/app",
"version": "0.1.24",
"version": "0.2.0",
"dependencies": {
"@cashu/cashu-ts": "0.6.1",
"@lightninglabs/lnc-web": "^0.2.8-alpha",
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@here/maps-api-for-javascript": "^1.50.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.3",
"@scure/base": "^1.1.1",
@ -14,6 +14,8 @@
"@snort/system-react": "workspace:*",
"@snort/system-wasm": "workspace:*",
"@snort/system-web": "workspace:*",
"@snort/wallet": "workspace:*",
"@snort/worker-relay": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.12",
@ -22,8 +24,10 @@
"debug": "^4.3.4",
"dexie": "^3.2.4",
"emojilib": "^3.0.10",
"eventemitter3": "^5.0.1",
"fuse.js": "^7.0.0",
"highlight.js": "^11.8.0",
"latlon-geohash": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"lottie-react": "^2.4.0",
"marked": "^9.1.0",
@ -40,9 +44,11 @@
"react-textarea-autosize": "^8.4.0",
"recharts": "^2.8.0",
"three": "^0.157.0",
"typescript-lru-cache": "^2.0.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^6.4.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
@ -82,6 +88,7 @@
"@formatjs/cli": "^6.1.3",
"@types/config": "^3.3.3",
"@types/debug": "^4.1.8",
"@types/latlon-geohash": "^2.0.3",
"@types/node": "^20.4.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
@ -112,8 +119,8 @@
"tailwindcss": "^3.3.3",
"tinybench": "^2.5.1",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0",
"vite": "^5.2.8",
"vite-plugin-pwa": "^0.19.2",
"vite-plugin-version-mark": "^0.0.10",
"vitest": "^0.34.6"
}

View File

@ -0,0 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,2 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,16 @@
import { ExternalStore } from "@snort/shared";
class CommunityLeadersStore extends ExternalStore<Array<string>> {
#leaders: Array<string> = [];
setLeaders(arr: Array<string>) {
this.#leaders = arr;
this.notifyChange();
}
takeSnapshot(): string[] {
return [...this.#leaders];
}
}
export const LeadersStore = new CommunityLeadersStore();

View File

@ -0,0 +1,100 @@
import { CachedTable, CacheEvents } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import { EventEmitter } from "eventemitter3";
export class EventCacheWorker extends EventEmitter<CacheEvents> implements CachedTable<NostrEvent> {
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, NostrEvent>();
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
async preload() {
const ids = await this.#relay.query([
"REQ",
"preload-event-cache",
{
ids_only: true,
},
]);
this.#keys = new Set<string>(ids as unknown as Array<string>);
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined): NostrEvent | undefined {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
this.#keys.add(this.key(ev));
}
async get(key?: string | undefined): Promise<NostrEvent | undefined> {
if (key) {
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]): Promise<NostrEvent[]> {
const results = await this.#relay.query([
"REQ",
"EventCacheWorker.bulkGet",
{
ids: keys,
},
]);
for (const ev of results) {
this.#cache.set(ev.id, ev);
}
return results;
}
async set(obj: NostrEvent): Promise<void> {
await this.#relay.event(obj);
this.#keys.add(obj.id);
}
async bulkSet(obj: NostrEvent[] | readonly NostrEvent[]): Promise<void> {
await Promise.all(
obj.map(async a => {
await this.#relay.event(a);
this.#keys.add(a.id);
}),
);
}
async update<TWithCreated extends NostrEvent & { created: number; loaded: number }>(
m: TWithCreated,
): Promise<"new" | "refresh" | "updated" | "no_change"> {
if (await this.#relay.event(m)) {
return "updated";
}
return "no_change";
}
async buffer(keys: string[]): Promise<string[]> {
const missing = keys.filter(a => !this.#keys.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: NostrEvent): string {
return of.id;
}
snapshot(): NostrEvent[] {
return [...this.#cache.values()];
}
}

View File

@ -1,42 +0,0 @@
import { FeedCache } from "@snort/shared";
import { db, EventInteraction } from "@/Db";
import { LoginStore } from "@/Utils/Login";
export class EventInteractionCache extends FeedCache<EventInteraction> {
constructor() {
super("EventInteraction", db.eventInteraction);
}
key(of: EventInteraction): string {
return `${of.event}:${of.by}`;
}
override async preload(): Promise<void> {
await super.preload();
const data = window.localStorage.getItem("zap-cache");
if (data) {
const toImport = [...new Set<string>(JSON.parse(data) as Array<string>)].map(a => {
const ret = {
event: a,
by: LoginStore.takeSnapshot().publicKey,
zapped: true,
reacted: false,
reposted: false,
} as EventInteraction;
ret.id = this.key(ret);
return ret;
});
await this.bulkSet(toImport);
console.debug(`Imported dumb-zap-cache events: `, toImport.length);
window.localStorage.removeItem("zap-cache");
}
await this.buffer([...this.onTable]);
}
takeSnapshot(): EventInteraction[] {
return [...this.cache.values()];
}
}

View File

@ -1,49 +0,0 @@
import { unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { db } from "@/Db";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache } from "./RefreshFeedCache";
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
constructor() {
super("FollowListCache", db.followLists);
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds([EventKind.ContactList])
.authors(session.follows.item)
.since(since === 0 ? undefined : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]) {
await Promise.all(
evs.map(async e => {
const update = await super.update({
...e,
created: e.created_at,
loaded: unixNowMs(),
});
if (update !== "no_change") {
socialGraphInstance.handleEvent(e);
}
}),
);
}
key(of: TaggedNostrEvent): string {
return of.pubkey;
}
takeSnapshot() {
return [...this.cache.values()];
}
override async preload() {
await super.preload();
this.snapshot().forEach(e => socialGraphInstance.handleEvent(e));
}
}

View File

@ -1,135 +0,0 @@
import { unixNow, unixNowMs } from "@snort/shared";
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import debug from "debug";
import { db } from "@/Db";
import { Day, Hour } from "@/Utils/Const";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
const WindowSize = Hour * 6;
const MaxCacheWindow = Day * 7;
export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
#kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
#oldest?: number;
constructor() {
super("FollowsFeedCache", db.followsFeed);
}
key(of: TWithCreated<TaggedNostrEvent>): string {
return of.id;
}
takeSnapshot(): TWithCreated<TaggedNostrEvent>[] {
return [...this.cache.values()];
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const authors = [...session.follows.item];
if (session.publicKey) {
authors.push(session.publicKey);
}
const since = this.newest();
rb.withFilter()
.kinds(this.#kinds)
.authors(authors)
.since(since === 0 ? unixNow() - WindowSize : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> {
const filtered = evs.filter(a => this.#kinds.includes(a.kind));
if (filtered.length > 0) {
await this.bulkSet(filtered);
this.notifyChange(filtered.map(a => this.key(a)));
}
}
override async preload() {
const start = unixNowMs();
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
this.onTable = new Set<string>(keys.map(a => a as string));
// load only latest 50 posts, rest can be loaded on-demand
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
latest?.forEach(v => this.cache.set(this.key(v), v));
// cleanup older than 7 days
await this.table
?.where("created_at")
.below(unixNow() - MaxCacheWindow)
.delete();
const oldest = await this.table?.orderBy("created_at").first();
this.#oldest = oldest?.created_at;
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
debug(this.name)(`Loaded %d/%d in %d ms`, latest?.length ?? 0, keys.length, (unixNowMs() - start).toLocaleString());
}
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];
if (session.publicKey) {
authors.push(session.publicKey);
}
rb.withFilter()
.kinds(this.#kinds)
.authors(authors)
.until(before)
.since(before - WindowSize);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
} else {
const latest = await this.table
?.where("created_at")
.between(before - WindowSize, before)
.reverse()
.sortBy("created_at");
latest?.forEach(v => {
const k = this.key(v);
this.cache.set(k, v);
this.onTable.add(k);
});
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
}
}
/**
* Backfill cache with new follows
*/
async backFill(system: SystemInterface, keys: Array<string>) {
if (keys.length === 0) return;
const rb = new RequestBuilder(`${this.name}-backfill`);
rb.withFilter()
.kinds(this.#kinds)
.authors(keys)
.until(unixNow())
.since(this.#oldest ?? unixNow() - MaxCacheWindow);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
}
/**
* Backfill cache based on follows list
*/
async backFillIfMissing(system: SystemInterface, keys: Array<string>) {
if (!this.#oldest) return;
const start = unixNowMs();
const everything = await this.table?.toArray();
if ((everything?.length ?? 0) > 0) {
const allKeys = new Set(everything?.map(a => a.pubkey));
const missingKeys = keys.filter(a => !allKeys.has(a));
await this.backFill(system, missingKeys);
debug(this.name)(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
}
}
}

View File

@ -1,47 +0,0 @@
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { db, NostrEventForSession } from "@/Db";
import { Day } from "@/Utils/Const";
import { LoginSession } from "@/Utils/Login";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
export class NotificationsCache extends RefreshFeedCache<NostrEventForSession> {
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
constructor() {
super("notifications", db.notifications);
}
buildSub(session: LoginSession, rb: RequestBuilder) {
if (session.publicKey) {
const newest = this.newest(v => v.tags.some(a => a[0] === "p" && a[1] === session.publicKey));
rb.withFilter()
.kinds(this.#kinds)
.tag("p", [session.publicKey])
.since(newest === 0 ? unixNow() - Day * 30 : newest);
}
}
async onEvent(evs: readonly TaggedNostrEvent[], pubKey: string) {
const filtered = evs.filter(a => this.#kinds.includes(a.kind) && a.tags.some(b => b[0] === "p"));
if (filtered.length > 0) {
await this.bulkSet(
filtered.map(v => ({
...v,
forSession: pubKey,
})),
);
this.notifyChange(filtered.map(v => this.key(v)));
}
}
key(of: TWithCreated<NostrEvent>): string {
return of.id;
}
takeSnapshot() {
return [...this.cache.values()];
}
}

View File

@ -1,17 +0,0 @@
import { FeedCache } from "@snort/shared";
import { db, Payment } from "@/Db";
export class Payments extends FeedCache<Payment> {
constructor() {
super("PaymentsCache", db.payments);
}
key(of: Payment): string {
return of.url;
}
takeSnapshot(): Array<Payment> {
return [...this.cache.values()];
}
}

View File

@ -0,0 +1,112 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, CachedMetadata>();
#log = debug("ProfileCacheRelayWorker");
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
async preload() {
const start = unixNowMs();
const profiles = await this.#relay.query([
"REQ",
"profiles-preload",
{
kinds: [0],
},
]);
this.#cache = new Map<string, CachedMetadata>(profiles.map(a => [a.pubkey, unwrap(mapEventToProfile(a))]));
this.#keys = new Set<string>(this.#cache.keys());
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined) {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
if (ev.kind === 0) {
this.#keys.add(ev.pubkey);
}
}
async get(key?: string | undefined) {
if (key) {
const cached = this.getFromCache(key);
if (cached) {
return cached;
}
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]) {
if (keys.length === 0) return [];
const results = await this.#relay.query([
"REQ",
"ProfileCacheRelayWorker.bulkGet",
{
authors: keys,
kinds: [0],
},
]);
const mapped = removeUndefined(results.map(a => mapEventToProfile(a)));
for (const pf of mapped) {
this.#cache.set(this.key(pf), pf);
}
this.emit(
"change",
mapped.map(a => this.key(a)),
);
return mapped;
}
async set(obj: CachedMetadata) {
this.#keys.add(this.key(obj));
}
async bulkSet(obj: CachedMetadata[] | readonly CachedMetadata[]) {
const mapped = obj.map(a => this.key(a));
mapped.forEach(a => this.#keys.add(a));
// todo: store in cache
this.emit("change", mapped);
}
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
// do nothing
return "refresh";
}
async buffer(keys: string[]) {
const missing = keys.filter(a => !this.#cache.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: CachedMetadata) {
return of.pubkey;
}
snapshot() {
return [...this.#cache.values()];
}
}

View File

@ -24,7 +24,6 @@ export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
override async preload(): Promise<void> {
await super.preload();
// load all dms to memory
await this.buffer([...this.onTable]);
}
}

View File

@ -0,0 +1,6 @@
import { ParsedFragment } from "@snort/system";
import { LRUCache } from "typescript-lru-cache";
export const TextCache = new LRUCache<string, Array<ParsedFragment>>({
maxSize: 1000,
});

View File

@ -0,0 +1,117 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { EventKind, NostrEvent, UsersFollows } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class UserFollowsWorker extends EventEmitter<CacheEvents> implements CachedTable<UsersFollows> {
#relay: WorkerRelayInterface;
#keys = new Set<string>();
#cache = new Map<string, UsersFollows>();
#log = debug("UserFollowsWorker");
constructor(relay: WorkerRelayInterface) {
super();
this.#relay = relay;
}
async preload() {
const start = unixNowMs();
const profiles = await this.#relay.query([
"REQ",
"profiles-preload",
{
kinds: [3],
},
]);
this.#cache = new Map<string, UsersFollows>(profiles.map(a => [a.pubkey, unwrap(mapEventToUserFollows(a))]));
this.#keys = new Set<string>(this.#cache.keys());
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
keysOnTable(): string[] {
return [...this.#keys];
}
getFromCache(key?: string | undefined): UsersFollows | undefined {
if (key) {
return this.#cache.get(key);
}
}
discover(ev: NostrEvent) {
this.#keys.add(ev.pubkey);
}
async get(key?: string | undefined): Promise<UsersFollows | undefined> {
if (key) {
const res = await this.bulkGet([key]);
if (res.length > 0) {
return res[0];
}
}
}
async bulkGet(keys: string[]) {
if (keys.length === 0) return [];
const results = await this.#relay.query([
"REQ",
"UserFollowsWorker.bulkGet",
{
authors: keys,
kinds: [3],
},
]);
const mapped = removeUndefined(results.map(a => mapEventToUserFollows(a)));
for (const pf of mapped) {
this.#cache.set(this.key(pf), pf);
}
this.emit(
"change",
mapped.map(a => this.key(a)),
);
return mapped;
}
async set(obj: UsersFollows) {
this.#keys.add(this.key(obj));
}
async bulkSet(obj: UsersFollows[] | readonly UsersFollows[]) {
const mapped = obj.map(a => this.key(a));
mapped.forEach(a => this.#keys.add(a));
// todo: store in cache
this.emit("change", mapped);
}
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
// do nothing
return "refresh";
}
async buffer(keys: string[]): Promise<string[]> {
const missing = keys.filter(a => !this.#keys.has(a));
const res = await this.bulkGet(missing);
return missing.filter(a => !res.some(b => this.key(b) === a));
}
key(of: UsersFollows): string {
return of.pubkey;
}
snapshot(): UsersFollows[] {
return [...this.#cache.values()];
}
}
export function mapEventToUserFollows(ev: NostrEvent): UsersFollows | undefined {
if (ev.kind !== EventKind.ContactList) return;
return {
pubkey: ev.pubkey,
loaded: unixNowMs(),
created: ev.created_at,
follows: ev.tags,
};
}

View File

@ -1,38 +1,45 @@
import { RelayMetricCache, UserProfileCache, UserRelaysCache } from "@snort/system";
import { RelayMetricCache, UserRelaysCache } from "@snort/system";
import { SnortSystemDb } from "@snort/system-web";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
import { ChatCache } from "./ChatCache";
import { EventInteractionCache } from "./EventInteractionCache";
import { FollowListCache } from "./FollowListCache";
import { FollowsFeedCache } from "./FollowsFeed";
import { EventCacheWorker } from "./EventCacheWorker";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { Payments } from "./PaymentsCache";
import { ProfileCacheRelayWorker } from "./ProfileWorkerCache";
import { UserFollowsWorker } from "./UserFollowsWorker";
export const Relay = new WorkerRelayInterface(
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
);
export async function initRelayWorker() {
try {
await Relay.init({
databasePath: "relay.db",
insertBatchSize: 100,
});
} catch (e) {
console.error(e);
}
}
export const SystemDb = new SnortSystemDb();
export const UserCache = new UserProfileCache(SystemDb.users);
export const UserRelays = new UserRelaysCache(SystemDb.userRelays);
export const RelayMetrics = new RelayMetricCache(SystemDb.relayMetrics);
export const Chats = new ChatCache();
export const PaymentsCache = new Payments();
export const InteractionCache = new EventInteractionCache();
export const UserFollows = new UserFollowsWorker(Relay);
export const UserCache = new ProfileCacheRelayWorker(Relay);
export const EventsCache = new EventCacheWorker(Relay);
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export const FollowsFeed = new FollowsFeedCache();
export const FollowLists = new FollowListCache();
export async function preload(follows?: Array<string>) {
const preloads = [
UserCache.preload(follows),
Chats.preload(),
InteractionCache.preload(),
UserRelays.preload(follows),
UserCache.preload(),
RelayMetrics.preload(),
GiftsCache.preload(),
Notifications.preload(),
FollowsFeed.preload(),
FollowLists.preload(),
UserRelays.preload(follows),
EventsCache.preload(),
UserFollows.preload(),
];
await Promise.all(preloads);
}

View File

@ -19,10 +19,10 @@
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
}
.light .spinner-button:hover {
.light .spinner-button:not(.primary):hover {
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
}
.light .spinner-button > span {
.light .spinner-button:not(.primary) > span {
color: black;
}

View File

@ -1,7 +1,6 @@
import classNames from "classnames";
import { ReactNode, useState } from "react";
import ShowMore from "@/Components/Event/ShowMore";
import Icon from "@/Components/Icons/Icon";
interface CollapsedProps {
@ -13,8 +12,8 @@ interface CollapsedProps {
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
return collapsed ? (
<div className="collapsed">
<ShowMore text={text} onClick={() => setCollapsed(false)} />
<div className="text-nostr-purple px-4 pb-3 cursor-pointer hover:underline" onClick={() => setCollapsed(false)}>
{text}
</div>
) : (
<div className="uncollapsed">{children}</div>

View File

@ -9,15 +9,21 @@ export interface CopyProps {
text: string;
maxSize?: number;
className?: string;
showText?: boolean;
mask?: string;
}
export default function Copy({ text, maxSize = 32, className }: CopyProps) {
export default function Copy({ text, maxSize = 32, className, showText, mask }: CopyProps) {
const { copy, copied } = useCopy();
const sliceLength = maxSize / 2;
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
const displayText = mask ? mask.repeat(text.length) : text;
const trimmed =
displayText.length > maxSize
? `${displayText.slice(0, sliceLength)}...${displayText.slice(-sliceLength)}`
: displayText;
return (
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
<span className="copy-body">{trimmed}</span>
{(showText ?? true) && <span className="copy-body">{trimmed}</span>}
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span>

View File

@ -3,13 +3,21 @@ const AppleMusicEmbed = ({ link }: { link: string }) => {
const isSongLink = /\?i=\d+$/.test(convertedUrl);
return (
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}></iframe>
<>
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
// eslint-disable-next-line react/no-unknown-property
credentialless=""
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -1,8 +1,3 @@
.cashu {
background: var(--cashu-gradient);
}
.cashu h1 {
font-size: 44px;
line-height: 1em;
}

View File

@ -4,7 +4,10 @@ import { useUserProfile } from "@snort/system-react";
import { useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import ECashIcon from "@/Components/Icons/ECash";
import Icon from "@/Components/Icons/Icon";
import { useCopy } from "@/Hooks/useCopy";
import useLogin from "@/Hooks/useLogin";
interface Token {
@ -20,13 +23,9 @@ interface Token {
export default function CashuNuts({ token }: { token: string }) {
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const profile = useUserProfile(publicKey);
const { copy } = useCopy();
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
await navigator.clipboard.writeText(token);
}
async function redeemToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
async function redeemToken(token: string) {
const lnurl = profile?.lud16 ?? "";
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
lnurl,
@ -53,87 +52,30 @@ export default function CashuNuts({ token }: { token: string }) {
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
return (
<div className="cashu flex justify-between p24 br">
<div className="flex flex-col g8 f-ellipsis">
<div className="flex items-center g16">
<svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 47711">
<path
id="Rectangle 585"
d="M29.3809 2.47055L29.3809 11.7277L26.7913 11.021C23.8493 10.2181 20.727 10.3835 17.8863 11.4929C15.5024 12.4238 12.9113 12.6933 10.3869 12.2728L7.11501 11.7277L7.11501 2.47054L10.3869 3.01557C12.9113 3.43607 15.5024 3.1666 17.8863 2.23566C20.727 1.12632 23.8493 0.960876 26.7913 1.7638L29.3809 2.47055Z"
fill="url(#paint0_linear_1976_19241)"
/>
<path
id="Rectangle 587"
d="M29.3809 27.9803L29.3809 37.2375L26.7913 36.5308C23.8493 35.7278 20.727 35.8933 17.8863 37.0026C15.5024 37.9336 12.9113 38.203 10.3869 37.7825L7.11501 37.2375L7.11501 27.9803L10.3869 28.5253C12.9113 28.9458 15.5024 28.6764 17.8863 27.7454C20.727 26.6361 23.8493 26.4706 26.7913 27.2736L29.3809 27.9803Z"
fill="url(#paint1_linear_1976_19241)"
/>
<path
id="Rectangle 586"
d="M8.494e-08 15.2069L4.89585e-07 24.4641L2.5896 23.7573C5.53159 22.9544 8.6539 23.1198 11.4946 24.2292C13.8784 25.1601 16.4695 25.4296 18.9939 25.0091L22.2658 24.4641L22.2658 15.2069L18.9939 15.7519C16.4695 16.1724 13.8784 15.9029 11.4946 14.972C8.6539 13.8627 5.53159 13.6972 2.5896 14.5001L8.494e-08 15.2069Z"
fill="url(#paint2_linear_1976_19241)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_1976_19241"
x1="29.3809"
y1="6.7213"
x2="7.11501"
y2="6.7213"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint1_linear_1976_19241"
x1="29.3809"
y1="32.2311"
x2="7.11501"
y2="32.2311"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint2_linear_1976_19241"
x1="2.70746e-07"
y1="19.4576"
x2="22.2658"
y2="19.4576"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
</defs>
</svg>
<div className="cashu flex justify-between p24 br items-center">
<div className="flex flex-col gap-2 f-ellipsis">
<div className="flex items-center gap-4">
<ECashIcon width={30} />
<FormattedMessage
defaultMessage="<h1>{n}</h1> Cashu sats"
id="6/SF6e"
defaultMessage="{n} eSats"
id="yAztTU"
values={{
h1: c => <h1>{c}</h1>,
n: <FormattedNumber value={amount} />,
n: (
<span className="text-3xl">
<FormattedNumber value={amount} />
</span>
),
}}
/>
</div>
<small className="xs w-max">
<FormattedMessage
defaultMessage="<b>Mint:</b> {url}"
id="zwb6LR"
values={{
b: c => <b>{c}</b>,
url: new URL(cashu.token[0].mint).hostname,
}}
/>
</small>
</div>
<div className="flex g8">
<button onClick={e => copyToken(e, token)}>
<div className="flex gap-2 items-center">
<AsyncButton onClick={() => copy(token)}>
<Icon name="copy" />
</button>
<button onClick={e => redeemToken(e, token)}>
</AsyncButton>
<AsyncButton onClick={() => redeemToken(token)}>
<FormattedMessage defaultMessage="Redeem" id="XrSk2j" description="Button: Redeem Cashu token" />
</button>
</AsyncButton>
</div>
</div>
);

View File

@ -0,0 +1,34 @@
import { useState } from "react";
import Icon from "../Icons/Icon";
import { ProxyImg } from "../ProxyImg";
export default function GenericPlayer({ url, poster }: { url: string; poster: string }) {
const [play, setPlay] = useState(false);
if (!play) {
return (
<div
className="relative aspect-video"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setPlay(true);
}}>
<ProxyImg className="absolute" src={poster} />
<div className="absolute w-full h-full opacity-0 hover:opacity-100 hover:bg-black/30 flex items-center justify-center transition">
<Icon name="play-square-outline" size={50} />
</div>
</div>
);
}
return (
<iframe
className="aspect-video w-full"
src={url}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
}

View File

@ -46,15 +46,22 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
if (youtubeId) {
return (
<iframe
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
</>
);
} else if (tidalId) {
return <TidalEmbed link={a} />;

View File

@ -7,7 +7,7 @@ import { useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import ZapModal from "@/Components/ZapModal/ZapModal";
import { useWallet } from "@/Wallet";
import messages from "../messages";
@ -36,7 +36,7 @@ export default function Invoice(props: InvoiceProps) {
<FormattedMessage {...messages.Invoice} />
</h4>
<Icon name="zapCircle" className="zap-circle" />
<SendSats
<ZapModal
title={formatMessage(messages.PayInvoice)}
invoice={invoice}
show={showInvoice}

View File

@ -38,7 +38,9 @@
font-size: 12px;
}
.link-preview-image {
.link-preview-container img,
.link-preview-container video,
.link-preview-container iframe {
margin: 0 0 15px 0 !important;
border-radius: 0 !important;
background-image: var(--img-url);

View File

@ -1,14 +1,17 @@
import "./LinkPreview.css";
import { CSSProperties, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { LRUCache } from "typescript-lru-cache";
import { MediaElement } from "@/Components/Embed/MediaElement";
import Spinner from "@/Components/Icons/Spinner";
import SnortApi, { LinkPreviewData } from "@/External/SnortApi";
import useImgProxy from "@/Hooks/useImgProxy";
import { LinkPreviewData, NostrServices } from "@/External/NostrServices";
import { ProxyImg } from "../ProxyImg";
import GenericPlayer from "./GenericPlayer";
async function fetchUrlPreviewInfo(url: string) {
const api = new SnortApi();
const api = new NostrServices("https://nostr.api.v0l.io");
try {
return await api.linkPreview(url.endsWith(")") ? url.slice(0, -1) : url);
} catch (e) {
@ -16,18 +19,23 @@ async function fetchUrlPreviewInfo(url: string) {
}
}
const cache = new LRUCache<string, LinkPreviewData>({
maxSize: 100,
});
const LinkPreview = ({ url }: { url: string }) => {
const [preview, setPreview] = useState<LinkPreviewData | null>();
const { proxy } = useImgProxy();
const [preview, setPreview] = useState<LinkPreviewData | null>(cache.get(url));
useEffect(() => {
(async () => {
if (preview) return;
const data = await fetchUrlPreviewInfo(url);
if (data) {
const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type");
const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false;
if (canPreviewType || data.image) {
setPreview(data);
cache.set(url, data);
return;
}
}
@ -49,9 +57,12 @@ const LinkPreview = ({ url }: { url: string }) => {
const urlTags = ["og:video:secure_url", "og:video:url", "og:video"];
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:video:type")?.[1] ?? "video/mp4";
if (link) {
if (link && videoType.startsWith("video/")) {
return <MediaElement url={link} mime={videoType} />;
}
if (link && videoType.startsWith("text/html") && preview?.image) {
return <GenericPlayer url={link} poster={preview?.image} />;
}
}
if (type?.startsWith("image")) {
const urlTags = ["og:image:secure_url", "og:image:url", "og:image"];
@ -62,9 +73,7 @@ const LinkPreview = ({ url }: { url: string }) => {
}
}
if (preview?.image) {
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
const style = { "--img-url": backgroundImage } as CSSProperties;
return <div className="link-preview-image" style={style}></div>;
return <ProxyImg src={preview?.image} className="w-full object-cover aspect-video" />;
}
return null;
}

View File

@ -11,6 +11,7 @@ interface MediaElementProps {
url: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
size?: number;
}
interface AudioElementProps {
@ -25,6 +26,7 @@ interface VideoElementProps {
interface ImageElementProps {
url: string;
meta?: IMeta;
size?: number;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
@ -32,7 +34,7 @@ const AudioElement = ({ url }: AudioElementProps) => {
return <audio key={url} src={url} controls />;
};
const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
const imageRef = useRef<HTMLImageElement | null>(null);
const style = useMemo(() => {
const style = {} as CSSProperties;
@ -47,10 +49,12 @@ const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
<div
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
"cursor-pointer": onMediaClick,
})}>
<ProxyImg
key={url}
src={url}
size={size}
sha256={meta?.sha256}
onClick={onMediaClick}
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
@ -87,6 +91,7 @@ const VideoElement = ({ url }: VideoElementProps) => {
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
})}>
<video
crossOrigin="anonymous"
ref={videoRef}
loop={true}
muted={!isMobile}
@ -102,7 +107,7 @@ const VideoElement = ({ url }: VideoElementProps) => {
export function MediaElement(props: MediaElementProps) {
if (props.mime.startsWith("image/")) {
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} />;
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} size={props.size} />;
} else if (props.mime.startsWith("audio/")) {
return <AudioElement url={props.url} />;
} else if (props.mime.startsWith("video/")) {

View File

@ -4,7 +4,7 @@ import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = useLogin(s => s.appData.item.preferences.theme);
const theme = useLogin(s => s.appData.json.preferences.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<>
@ -16,6 +16,9 @@ const MixCloudEmbed = ({ link }: { link: string }) => {
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
</>
);
};

View File

@ -8,10 +8,6 @@ export default function NostrLink({ link, depth }: { link: string; depth?: numbe
const nav = tryParseNostrLink(link);
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
if (nav.id.startsWith("npub")) {
// eslint-disable-next-line no-debugger
debugger;
}
return <Mention link={nav} />;
} else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) {
if ((depth ?? 0) > 0) {

View File

@ -1,5 +1,6 @@
import { LNURL } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { WalletInvoiceState } from "@snort/wallet";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { UserCache } from "@/Cache";
@ -10,7 +11,6 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet";
import { WalletInvoiceState } from "@/Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
@ -22,7 +22,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
for (const pk of ids) {
try {
const profile = await UserCache.get(pk);
const amtSend = login.appData.item.preferences.defaultZapAmount;
const amtSend = login.appData.json.preferences.defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) {
const svc = new LNURL(lnurl);
@ -74,7 +74,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={login.appData.item.preferences.defaultZapAmount * ids.length} />,
n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>

View File

@ -1,11 +1,19 @@
const SoundCloudEmbed = ({ link }: { link: string }) => {
return (
<iframe
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}></iframe>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -2,14 +2,22 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return (
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"></iframe>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -46,13 +46,25 @@ const TidalEmbed = ({ link }: { link: string }) => {
.catch(console.error);
}, [link]);
if (!source)
if (!source) {
return (
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
);
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} />;
}
const iframe = (
// eslint-disable-next-line react/no-unknown-property
<iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} credentialless="" />
);
return (
<>
{iframe}
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};
export default TidalEmbed;

View File

@ -2,7 +2,14 @@ const TwitchEmbed = ({ link }: { link: string }) => {
const channel = link.split("/").slice(-1);
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return <iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true}></iframe>;
return (
<>
<iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true} />
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};
export default TwitchEmbed;

View File

@ -2,13 +2,21 @@ const WavlakeEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return (
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="380"
frameBorder="0"
loading="lazy"></iframe>
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="380"
frameBorder="0"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -1,10 +1,11 @@
import React from "react";
import {trackEvent} from "@/Utils";
import { trackEvent } from "@/Utils";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage?: string;
stack?: string;
}
interface ErrorBoundaryProps {
@ -18,7 +19,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, errorMessage: error.message };
return { hasError: true, errorMessage: error.message, stack: error.stack };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
@ -33,6 +34,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
<div className="p-2">
<h1>Something went wrong.</h1>
<p>Error: {this.state.errorMessage}</p>
<pre className="text-xs overflow-auto mt-8">{this.state.stack}</pre>
</div>
);
}

View File

@ -36,7 +36,6 @@
padding: 0;
border-radius: 0;
margin: 8px 12px;
background-color: var(--gray-superdark);
min-height: 100px;
width: stretch;
width: -webkit-fill-available;

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import "./NoteCreator.css";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
@ -41,7 +42,6 @@ const replyToNoteOptions = {
showProfileCard: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
};
@ -50,14 +50,13 @@ const quoteNoteOptions = {
showContextMenu: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
};
export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.item.preferences.pow }));
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.json.preferences.pow }));
const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
const note = useNoteCreator();
@ -317,7 +316,9 @@ export function NoteCreator() {
function getPreviewNote() {
if (note.preview) {
return <Note data={note.preview as TaggedNostrEvent} options={previewNoteOptions} />;
return (
<Note className="hover:bg-transparent" data={note.preview as TaggedNostrEvent} options={previewNoteOptions} />
);
}
}
@ -614,7 +615,10 @@ export function NoteCreator() {
<h4>
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
</h4>
<Note data={note.replyTo} options={replyToNoteOptions} />
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.replyTo} options={replyToNoteOptions} />
</div>
<hr className="border-border-color border-1 -mx-6" />
</>
)}
{note.quote && (
@ -622,7 +626,10 @@ export function NoteCreator() {
<h4>
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</h4>
<Note data={note.quote} options={quoteNoteOptions} />
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.quote} options={quoteNoteOptions} />
</div>
<hr className="border-border-color border-1 -mx-6" />
</>
)}
{note.preview && getPreviewNote()}

View File

@ -8,7 +8,7 @@ import IconButton from "@/Components/Button/IconButton";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { saveRelays } from "@/Pages/settings/Relays";
import { saveRelays } from "@/Pages/settings/saveRelays";
import { getRelayName } from "@/Utils";
import { removeRelay } from "@/Utils/Login";

View File

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

View File

@ -57,21 +57,6 @@
margin-top: 16px;
}
.note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.note .footer .footer-reactions {
margin-left: 0;
}
}
.note > .header img:hover,
.note > .header .name > .reply:hover {
cursor: pointer;
@ -115,13 +100,7 @@
}
.reaction-pill {
display: flex;
min-width: 1rem;
align-items: center;
justify-content: center;
user-select: none;
font-feature-settings: "tnum";
gap: 5px;
}
.reaction-pill:not(.reacted):not(:hover) {
@ -137,15 +116,6 @@
overflow-y: hidden;
}
.hidden-note .header {
display: flex;
align-items: center;
}
.card.note.hidden-note {
min-height: unset;
}
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;

View File

@ -41,7 +41,7 @@ export interface NoteProps {
ignoreModeration?: boolean;
onClick?: (e: TaggedNostrEvent) => void;
depth?: number;
searchedValue?: string;
highlightText?: string;
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: NotePropsOptions;

View File

@ -1,22 +1,23 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import messages from "../messages";
import useLogin from "@/Hooks/useLogin";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const hideMutedNotes = useLogin(s => s.appData.json.preferences.hideMutedNotes);
const [show, setShow] = useState(false);
if (hideMutedNotes) return;
return show ? (
children
) : (
<div className="card note hidden-note">
<div className="header">
<p>
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
</p>
<button type="button" onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
<div className="bb p flex items-center justify-between">
<div className="text-sm text-secondary">
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
</div>
<button className="btn btn-sm btn-neutral" onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
</button>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import usePageDimensions from "@/Hooks/usePageDimensions";
import { debounce } from "@/Utils";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const LoadMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<button type="button" className={className} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Load more" id="00LcfG" />}
</button>
);
};
export default LoadMore;
export function AutoLoadMore({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView({ rootMargin: "1000px" });
const { height } = usePageDimensions();
useEffect(() => {
if (inView) {
// TODO improve feed performance. Something in image grid makes it slow when feed size grows.
return debounce(100, onClick);
}
}, [inView, height]);
return (
<div ref={ref}>
<LoadMore onClick={onClick} text={text} className={className} />
</div>
);
}

View File

@ -46,22 +46,3 @@
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.long-form-note .footer {
display: flex;
}
.long-form-note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.long-form-note .footer .footer-reactions {
margin-left: 0;
}
}

View File

@ -12,7 +12,7 @@ import useImgProxy from "@/Hooks/useImgProxy";
import { findTag } from "@/Utils";
import { Markdown } from "./Markdown";
import NoteFooter from "./Note/NoteFooter";
import NoteFooter from "./Note/NoteFooter/NoteFooter";
import NoteTime from "./Note/NoteTime";
interface LongFormTextProps {
@ -32,13 +32,8 @@ export function LongFormText(props: LongFormTextProps) {
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const related = useReactions(
NostrLink.fromEvent(props.ev).id + "related",
[NostrLink.fromEvent(props.ev)],
undefined,
false,
);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related.data ?? []);
const related = useReactions("note:reactions", [NostrLink.fromEvent(props.ev)], undefined, false);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related);
function previewText() {
return (
@ -154,7 +149,9 @@ export function LongFormText(props: LongFormTextProps) {
}
return (
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
<div
className={classNames("long-form-note flex flex-col g16 p break-words", { "cursor-pointer": props.isPreview })}
onClick={props.onClick}>
<ProfilePreview
pubkey={props.ev.pubkey}
actions={

View File

@ -10,8 +10,8 @@ import { findTag } from "@/Utils";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
if (!ev.data) return <PageSpinner />;
return <NostrFileElement ev={ev.data} />;
if (!ev) return <PageSpinner />;
return <NostrFileElement ev={ev} />;
}
export function NostrFileElement({ ev }: { ev: NostrEvent }) {

View File

@ -1,24 +1,28 @@
import { EventKind, NostrLink } from "@snort/system";
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import React, { useCallback, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { LRUCache } from "typescript-lru-cache";
import { Relay } from "@/Cache";
import NoteHeader from "@/Components/Event/Note/NoteHeader";
import NoteQuote from "@/Components/Event/Note/NoteQuote";
import { NoteText } from "@/Components/Event/Note/NoteText";
import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Username from "@/Components/User/Username";
import useModeration from "@/Hooks/useModeration";
import { chainKey } from "@/Hooks/useThreadContext";
import { findTag } from "@/Utils";
import { chainKey } from "@/Utils/Thread/ChainKey";
import messages from "../../messages";
import Text from "../../Text/Text";
import { NoteProps } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import { NoteTranslation } from "./NoteContextMenu";
import NoteFooter from "./NoteFooter";
import NoteFooter from "./NoteFooter/NoteFooter";
const defaultOptions = {
showHeader: true,
@ -30,14 +34,33 @@ const defaultOptions = {
};
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
const translationCache = new LRUCache<string, NoteTranslation>({ maxSize: 300 });
export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>();
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
const cachedSetTranslated = useCallback(
(translation: NoteTranslation) => {
translationCache.set(ev.id, translation);
setTranslated(translation);
},
[ev.id],
);
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) {
timeout = setTimeout(() => {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}, 1000);
}
return () => clearTimeout(timeout);
}, [setSeenAtInView]);
const optionsMerged = { ...defaultOptions, ...opt };
const goToEvent = useGoToEvent(props, optionsMerged);
@ -50,13 +73,24 @@ export function Note(props: NoteProps) {
if (waitUntilInView && !inView) return null;
return (
<>
{optionsMerged.showHeader && <NoteHeader ev={ev} options={optionsMerged} setTranslated={setTranslated} />}
{optionsMerged.showHeader && (
<NoteHeader
ev={ev}
options={optionsMerged}
setTranslated={translated === null ? cachedSetTranslated : undefined}
/>
)}
<div className="body" onClick={e => goToEvent(e, ev)}>
<NoteText {...props} translated={translated} showTranslation={showTranslation} />
{translated && <TranslationInfo translated={translated} setShowTranslation={setShowTranslation} />}
{ev.kind === EventKind.Polls && <Poll ev={ev} />}
{ev.kind === EventKind.Polls && <Poll ev={ev} zaps={[]} />}
{optionsMerged.showFooter && (
<div className="mt-4">
<NoteFooter ev={ev} replyCount={props.threadChains?.get(chainKey(ev))?.length} />
</div>
)}
<div ref={setSeenAtRef} />
</div>
{optionsMerged.showFooter && <NoteFooter ev={ev} replies={props.threadChains?.get(chainKey(ev))?.length} />}
</>
);
}
@ -114,7 +148,26 @@ function useGoToEvent(props, options) {
);
}
function handleNonTextNote(ev) {
function Reaction({ ev }: { ev: TaggedNostrEvent }) {
const reactedToTag = ev.tags.findLast(tag => tag[0] === "e");
const pTag = ev.tags.findLast(tag => tag[0] === "p");
if (!reactedToTag?.length) {
return null;
}
const link = NostrLink.fromTag(reactedToTag, pTag?.[1]);
return (
<div className="note card">
<div className="text-gray-medium font-bold">
<Username pubkey={ev.pubkey} onLinkVisit={() => {}} />
<span> </span>
<FormattedMessage defaultMessage="liked" id="TvKqBp" />
</div>
<NoteQuote link={link} />
</div>
);
}
function handleNonTextNote(ev: TaggedNostrEvent) {
const alt = findTag(ev, "alt");
if (alt) {
return (
@ -122,6 +175,8 @@ function handleNonTextNote(ev) {
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else if (ev.kind === EventKind.Reaction) {
return <Reaction ev={ev} />;
} else {
return (
<>

View File

@ -1,8 +1,9 @@
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { HexKey, NostrLink, NostrPrefix } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { NoteContextMenuProps, NoteTranslation } from "@/Components/Event/Note/types";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import SnortApi from "@/External/SnortApi";
@ -14,20 +15,7 @@ import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
import { ReBroadcaster } from "../../ReBroadcaster";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
const { formatMessage } = useIntl();
const login = useLogin();
const { mute, block } = useModeration();
@ -60,6 +48,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
}
async function translate() {
if (!props.onTranslated) return;
const api = new SnortApi();
const targetLang = lang.split("-")[0].toUpperCase();
const result = await api.translate({
@ -67,24 +56,29 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
target_lang: targetLang,
});
if ("translations" in result) {
if (
typeof props.onTranslated === "function" &&
result.translations.length > 0 &&
targetLang != result.translations[0].detected_source_language
) {
props.onTranslated({
text: result.translations[0].text,
fromLanguage: langNames.of(result.translations[0].detected_source_language),
confidence: 1,
} as NoteTranslation);
}
if (
"translations" in result &&
result.translations.length > 0 &&
targetLang != result.translations[0].detected_source_language
) {
props.onTranslated({
text: result.translations[0].text,
fromLanguage: langNames.of(result.translations[0].detected_source_language),
confidence: 1,
} as NoteTranslation);
} else {
props.onTranslated({
text: "",
fromLanguage: "",
confidence: 0,
skipped: true,
});
}
}
useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) {
if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) {
translate();
}
}, []);
@ -106,10 +100,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.bookmarks(
es.map(a => new NostrLink(NostrPrefix.Note, a)),
"bookmark",
);
const ev = await publisher.bookmarks(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
@ -163,12 +154,6 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
<FormattedMessage {...messages.Mute} />
</MenuItem>
)}
{login.appData.item.preferences.enableReactions && !login.readonly && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />

View File

@ -1,322 +0,0 @@
import { barrierQueue, normalizeReaction, processWorkQueue, WorkQueueItem } from "@snort/shared";
import { countLeadingZeros, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import React, { forwardRef, useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { ZapsSummary } from "@/Components/Event/Zap";
import Icon from "@/Components/Icons/Icon";
import SendSats from "@/Components/SendSats/SendSats";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
import { findTag, getDisplayName } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
import messages from "../../messages";
const ZapperQueue: Array<WorkQueueItem> = [];
processWorkQueue(ZapperQueue);
export interface NoteFooterProps {
replies?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const ids = useMemo(() => [link], [link]);
const related = useReactions(link.id + "related", ids, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
const { positive } = reactions;
const { formatMessage } = useIntl();
const {
publicKey,
preferences: prefs,
readonly,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const { publisher, system } = useEventPublisher();
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const canFastZap = wallet?.isReady() && !readonly;
const isMine = ev.pubkey === publicKey;
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
},
);
function hasReacted(emoji: string) {
return (
interactionCache.data.reacted ||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
}
function hasReposted() {
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
interactionCache.react();
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
}
function getZapTarget(): Array<ZapTarget> | undefined {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: link,
},
} as ZapTarget,
];
}
}
async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setTip(true);
}
} finally {
setZapping(false);
}
} else {
setTip(true);
}
}
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierQueue(ZapperQueue, async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
await interactionCache.zap();
}
});
}
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap, author, zapping]);
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
<AsyncFooterIcon
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={pow}
/>
);
}
}
function tipButton() {
const targets = getZapTarget();
if (targets) {
return (
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
return null;
}
function repostIcon() {
if (readonly) return;
return (
<Menu
menuButton={
<AsyncFooterIcon
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => repost()} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);
}
function reactionIcon() {
if (!prefs.enableReactions) {
return null;
}
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positive.length}
onClick={async () => {
if (readonly) return;
await react(prefs.reactionEmoji);
}}
/>
);
}
function replyIcon() {
if (readonly) return;
return (
<AsyncFooterIcon
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={props.replies ?? 0}
onClick={async () => handleReplyButtonClick()}
/>
);
}
const handleReplyButtonClick = () => {
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<>
<div className="footer">
<div className="footer-reactions">
{replyIcon()}
{repostIcon()}
{reactionIcon()}
{tipButton()}
{powIcon()}
</div>
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
<ZapsSummary zaps={zaps} />
</>
);
}
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
};
return (
<AsyncIcon ref={ref} {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -0,0 +1,21 @@
import classNames from "classnames";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { formatShort } from "@/Utils/Number";
export const AsyncFooterIcon = (props: AsyncIconProps & { value: number }) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames(
"transition duration-200 ease-in-out flex flex-row reaction-pill cursor-pointer gap-2 items-center",
props.className,
),
};
return (
<AsyncIcon {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
};

View File

@ -0,0 +1,158 @@
import { barrierQueue } from "@snort/shared";
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import { ZapperQueue } from "@/Components/Event/Note/NoteFooter/ZapperQueue";
import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
export interface ZapIconProps {
ev: TaggedNostrEvent;
zaps: Array<ParsedZap>;
onClickZappers?: () => void;
}
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const {
publicKey,
readonly,
preferences: prefs,
} = useLogin(s => ({
publicKey: s.publicKey,
readonly: s.readonly,
preferences: s.appData.json.preferences,
}));
const walletState = useWallet();
const wallet = walletState.wallet;
const link = NostrLink.fromEvent(ev);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some(a => a.sender === publicKey);
const [showZapModal, setShowZapModal] = useState(false);
const { formatMessage } = useIntl();
const [zapping, setZapping] = useState(false);
const { publisher, system } = useEventPublisher();
const author = useUserProfile(ev.pubkey);
const isMine = ev.pubkey === publicKey;
const longPress = useLongPress(() => setShowZapModal(true), { captureEvent: true });
const getZapTarget = (): Array<ZapTarget> | undefined => {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: link,
},
} as ZapTarget,
];
}
};
const fastZap = async (e: React.MouseEvent) => {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setShowZapModal(true);
}
} finally {
setZapping(false);
}
} else {
setShowZapModal(true);
}
};
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierQueue(ZapperQueue, async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
}
});
}
}
const canFastZap = wallet?.isReady() && !readonly;
const targets = getZapTarget();
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap, author, zapping]);
return (
<>
{targets && (
<>
<div className="flex flex-row flex-none min-w-[50px] md:min-w-[80px] gap-4 items-center">
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={fastZap}
/>
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => {})} />
</div>
{showZapModal && (
<ZapModal
targets={getZapTarget()}
onClose={() => setShowZapModal(false)}
note={ev.id}
show={true}
allocatePool={true}
/>
)}
</>
)}
</>
);
};

View File

@ -0,0 +1,53 @@
import { normalizeReaction } from "@snort/shared";
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
export const LikeButton = ({
ev,
positiveReactions,
}: {
ev: TaggedNostrEvent;
positiveReactions: TaggedNostrEvent[];
}) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const { publisher, system } = useEventPublisher();
const hasReacted = (emoji: string) => {
return positiveReactions?.some(
({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey,
);
};
const react = async (content: string) => {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
}
if (!publisher) {
navigate("/login");
}
};
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
reacted ? "reacted text-nostr-red" : "hover:text-nostr-red",
)}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positiveReactions.length}
onClick={() => react("+")}
/>
);
};

View File

@ -0,0 +1,44 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import React, { useMemo, useState } from "react";
import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton";
import { LikeButton } from "@/Components/Event/Note/NoteFooter/LikeButton";
import { PowIcon } from "@/Components/Event/Note/NoteFooter/PowIcon";
import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin";
export interface NoteFooterProps {
replyCount?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev } = props;
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
const ids = useMemo(() => [link], [link]);
const [showReactions, setShowReactions] = useState(false);
const related = useReactions("reactions", ids, undefined, false);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;
const { preferences: prefs, readonly } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
readonly: s.readonly,
}));
return (
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
<RepostButton ev={ev} reposts={reposts} />
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{CONFIG.showPowIcon && <PowIcon ev={ev} />}
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}
</div>
);
}

View File

@ -0,0 +1,21 @@
import { countLeadingZeros, TaggedNostrEvent } from "@snort/system";
import { useIntl } from "react-intl";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import { findTag } from "@/Utils";
export const PowIcon = ({ ev }: { ev: TaggedNostrEvent }) => {
const { formatMessage } = useIntl();
const powValue = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (!powValue) return null;
return (
<AsyncFooterIcon
className="hidden md:flex flex-none min-w-[50px] md:min-w-[80px]"
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={powValue}
/>
);
};

View File

@ -0,0 +1,58 @@
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
export const ReplyButton = ({
ev,
replyCount,
readonly,
}: {
ev: TaggedNostrEvent;
replyCount?: number;
readonly: boolean;
}) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const publicKey = useLogin(s => s.publicKey);
const note = useNoteCreator(n => ({
show: n.show,
replyTo: n.replyTo,
update: n.update,
quote: n.quote,
}));
const handleReplyButtonClick = () => {
if (!publicKey) {
navigate("/login");
return;
}
if (readonly) {
return;
}
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple",
)}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={replyCount ?? 0}
onClick={handleReplyButtonClick}
/>
);
};

View File

@ -0,0 +1,77 @@
import { TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const { publisher, system } = useEventPublisher();
const { publicKey, preferences: prefs } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
}));
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const hasReposted = () => {
return reposts.some(a => a.pubkey === publicKey);
};
const repost = async () => {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
}
}
if (!publisher) {
navigate("/login");
}
};
return (
<Menu
menuButton={
<AsyncFooterIcon
className={classNames(
"flex-none min-w-[50px] md:min-w-[80px]",
hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue",
)}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={repost} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);
};

View File

@ -0,0 +1,5 @@
import { processWorkQueue, WorkQueueItem } from "@snort/shared";
export const ZapperQueue: Array<WorkQueueItem> = [];
processWorkQueue(ZapperQueue);

View File

@ -3,10 +3,11 @@ import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { NotePropsOptions } from "@/Components/Event/EventComponent";
import { NoteContextMenu, NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import { NoteContextMenu } from "@/Components/Event/Note/NoteContextMenu";
import NoteTime from "@/Components/Event/Note/NoteTime";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import ReplyTag from "@/Components/Event/Note/ReplyTag";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import ProfileImage from "@/Components/User/ProfileImage";
@ -17,7 +18,7 @@ import { setBookmarked, setPinned } from "@/Utils/Login";
export default function NoteHeader(props: {
ev: TaggedNostrEvent;
options: NotePropsOptions;
setTranslated: (t: NoteTranslation) => void;
setTranslated?: (t: NoteTranslation) => void;
context?: React.ReactNode;
}) {
const [showReactions, setShowReactions] = useState(false);
@ -49,6 +50,8 @@ export default function NoteHeader(props: {
}
}
const onTranslated = setTranslated ? (t: NoteTranslation) => setTranslated(t) : undefined;
return (
<div className="header flex">
<ProfileImage
@ -79,12 +82,12 @@ export default function NoteHeader(props: {
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
onTranslated={onTranslated}
setShowReactions={setShowReactions}
/>
)}
</div>
<ReactionsModal show={showReactions} setShow={setShowReactions} event={ev} />
{showReactions && <ReactionsModal onClose={() => setShowReactions(false)} event={ev} />}
</div>
);
}

View File

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

View File

@ -1,22 +1,22 @@
import React, { useState } from "react";
import React, { memo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text";
import useLogin from "@/Hooks/useLogin";
const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = function InnerContent(
export const NoteText = memo(function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
) {
const { data: ev, options, translated, showTranslation } = props;
const appData = useLogin(s => s.appData);
const [showMore, setShowMore] = useState(false);
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = options?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
const ToggleShowMore = () => (
@ -40,7 +40,7 @@ export const NoteText = function InnerContent(
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
highlightText={props.highlightText}
content={body}
tags={ev.tags}
creator={ev.pubkey}
@ -53,7 +53,7 @@ export const NoteText = function InnerContent(
</>
);
if (!appData.item.showContentWarningPosts) {
if (!appData.json.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
@ -94,4 +94,4 @@ export const NoteText = function InnerContent(
}
}
return innerContent;
};
});

View File

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
@ -38,7 +38,7 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
}
}, []);
const [time, setTime] = useState<string | ReactNode>(calcTime(from));
const [time] = useState<string | ReactNode>(calcTime(from));
const absoluteTime = useMemo(
() =>
@ -51,15 +51,6 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
useEffect(() => {
const t = setInterval(() => {
const newTime = calcTime(from);
setTime(s => (s !== newTime ? newTime : s));
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}

View File

@ -2,35 +2,34 @@ import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useMemo, useState } from "react";
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import Tabs from "@/Components/Tabs/Tabs";
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
import ProfileImage from "@/Components/User/ProfileImage";
import { formatShort } from "@/Utils/Number";
import messages from "../../messages";
interface ReactionsModalProps {
show: boolean;
setShow(b: boolean): void;
onClose(): void;
event: TaggedNostrEvent;
initialTab?: number;
}
const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const link = NostrLink.fromEvent(event);
const related = useReactions(link.id + "related", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
const related = useReactions("note:reactions", [link], undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions;
const sortEvents = events =>
const sortEvents = (events: Array<TaggedNostrEvent>) =>
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
@ -41,11 +40,12 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
const total = positive.length + negative.length + zaps.length + reposts.length;
const createTab = (message, count, value, disabled = false) => ({
text: formatMessage(message, { n: count }),
value,
disabled,
});
const createTab = (message: MessageDescriptor, count: number, value: number, disabled = false) =>
({
text: formatMessage(message, { n: count }),
value,
disabled,
}) as Tab;
const tabs = useMemo(() => {
const baseTabs = [
@ -57,24 +57,18 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
return dislikes.length !== 0 ? baseTabs.concat(createTab(messages.Dislikes, dislikes.length, 3)) : baseTabs;
}, [likes.length, zaps.length, reposts.length, dislikes.length, formatMessage]);
const [tab, setTab] = useState(tabs[0]);
const [tab, setTab] = useState(tabs[initialTab]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show, tabs]);
const renderReactionItem = (ev, icon, size) => (
const renderReactionItem = (ev: TaggedNostrEvent, icon: string, iconClass?: string, size?: number) => (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name={icon} size={size} />
<Icon name={icon} size={size} className={iconClass} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
return show ? (
return (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
@ -82,16 +76,16 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<TabSelectors tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart"))}
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap" size={20} />
<Icon name="zap-solid" size={20} className="text-zap" />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
@ -106,11 +100,11 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", 16))}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</Modal>
) : null;
);
};
export default ReactionsModal;

View File

@ -1,7 +1,7 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
import { NoteTranslation } from "@/Components/Event/Note/types";
import messages from "@/Components/messages";
interface TranslationInfoProps {
@ -23,7 +23,7 @@ export function TranslationInfo({ translated, setShowTranslation }: TranslationI
</span>
</>
);
} else if (translated) {
} else if (translated && !translated.skipped) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />

View File

@ -0,0 +1,18 @@
import { TaggedNostrEvent } from "@snort/system";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
skipped?: boolean;
}
export interface NoteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}

View File

@ -5,7 +5,7 @@ import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import Spinner from "@/Components/Icons/Spinner";
import SendSats from "@/Components/SendSats/SendSats";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { unwrap } from "@/Utils";
@ -27,18 +27,18 @@ export default function Poll(props: PollProps) {
preferences: prefs,
publicKey: myPubKey,
relays,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, relays: s.relays }));
} = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState<number>();
const didVote = props.zaps.some(a => a.sender === myPubKey);
const didVote = props.zaps?.some(a => a.sender === myPubKey);
const isMyPoll = props.ev.pubkey === myPubKey;
const showResults = didVote || isMyPoll;
const options = props.ev.tags
.filter(a => a[0] === "poll_option")
?.filter(a => a[0] === "poll_option")
.sort((a, b) => (Number(a[1]) > Number(b[1]) ? 1 : -1));
async function zapVote(ev: React.MouseEvent, opt: number) {
@ -107,9 +107,9 @@ export default function Poll(props: PollProps) {
const totalVotes = (() => {
switch (tallyBy) {
case "zaps":
return props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
return props.zaps?.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0) ?? 0;
case "pubkeys":
return new Set(props.zaps.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender))).size;
return new Set(props.zaps?.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender)) ?? []).size;
}
})();
@ -141,10 +141,10 @@ export default function Poll(props: PollProps) {
</button>
</div>
<div className="poll-body">
{options.map(a => {
{options?.map(a => {
const opt = Number(a[1]);
const desc = a[2];
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
const zapsOnOption = props.zaps?.filter(b => b.pollOption === opt) ?? [];
const total = (() => {
switch (tallyBy) {
case "zaps":
@ -172,7 +172,7 @@ export default function Poll(props: PollProps) {
{error && <b className="error">{error}</b>}
</div>
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
<ZapModal show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
</>
);
}

View File

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { MediaElement } from "@/Components/Embed/MediaElement";
import Reveal from "@/Components/Event/Reveal";
import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin";
import { FileExtensionRegex } from "@/Utils/Const";
@ -12,16 +13,17 @@ interface RevealMediaProps {
link: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
meta?: IMeta;
size?: number;
}
export default function RevealMedia(props: RevealMediaProps) {
const { preferences, follows, publicKey } = useLogin(s => ({
preferences: s.appData.item.preferences,
follows: s.follows.item,
const { preferences, publicKey } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey,
}));
const { isFollowing } = useFollowsControls();
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !isFollowing(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;
@ -73,6 +75,7 @@ export default function RevealMedia(props: RevealMediaProps) {
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
size={props.size}
/>
</Reveal>
);
@ -83,6 +86,7 @@ export default function RevealMedia(props: RevealMediaProps) {
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
size={props.size}
/>
);
}

View File

@ -1,18 +0,0 @@
.show-more {
background: none;
border: none;
color: var(--highlight);
font-weight: normal;
}
.show-more:hover {
color: var(--highlight);
background: none;
border: none;
font-weight: normal;
text-decoration: underline;
}
.show-more-container {
min-height: 40px;
}

View File

@ -1,40 +0,0 @@
import "./ShowMore.css";
import classNames from "classnames";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<div className="show-more-container">
<button className={classNames("show-more", className)} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Show More" id="O8Z8t9" />}
</button>
</div>
);
};
export default ShowMore;
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView({ rootMargin: "2000px" });
useEffect(() => {
if (inView) {
onClick();
}
}, [inView]);
return (
<div className={classNames("show-more-container", className)} ref={ref}>
{text}
</div>
);
}

View File

@ -1,348 +0,0 @@
import "./Thread.css";
import { EventExt, NostrPrefix, parseNostrLink, TaggedNostrEvent, u256 } from "@snort/system";
import classNames from "classnames";
import { Fragment, ReactNode, useCallback, useContext, useMemo, useState } from "react";
import { useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import BackButton from "@/Components/Button/BackButton";
import Collapsed from "@/Components/Collapsed";
import Note from "@/Components/Event/EventComponent";
import NoteGhost from "@/Components/Event/Note/NoteGhost";
import { chainKey, ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
import messages from "../messages";
interface DividerProps {
variant?: "regular" | "small";
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}></div>
</div>
);
};
interface SubthreadProps {
isLastSubthread?: boolean;
active: u256;
notes: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
onNavigate: (e: TaggedNostrEvent) => void;
}
const Subthread = ({ active, notes, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.id, chains);
return (
<Fragment key={a.id}>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
highlight={active === a.id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data={a}
key={a.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
)}
</Fragment>
);
};
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: TaggedNostrEvent;
isLast: boolean;
idx: number;
}
const ThreadNote = ({ active, note, isLast, isLastSubthread, chains, onNavigate, idx }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 1;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = classNames(
"subthread-container",
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid",
);
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.id}
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
data={note}
key={note.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)}
</>
);
};
const TierTwo = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
<ThreadNote
active={active}
onNavigate={onNavigate}
note={first}
chains={chains}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
idx={0}
/>
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
key={r.id}
active={active}
onNavigate={onNavigate}
note={r}
chains={chains}
isLastSubthread={isLastSubthread}
isLast={lastReply}
idx={idx}
/>
);
})}
</>
);
};
const TierThree = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div
className={classNames("subthread-container", {
"subthread-multi": hasMultipleNotes,
"subthread-last": isLast,
"subthread-mid": !isLast,
})}>
<Divider variant="small" />
<Note
highlight={active === first.id}
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
data={first}
key={first.id}
threadChains={chains}
waitUntilInView={true}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.id}
className={classNames("subthread-container", {
"subthread-multi": !lastReply,
"subthread-last": !lastReply,
"subthread-mid": lastReply,
})}>
<Divider variant="small" />
<Note
className={classNames("thread-note", { "is-last-note": lastNote })}
highlight={active === r.id}
data={r}
key={r.id}
onClick={onNavigate}
threadChains={chains}
waitUntilInView={idx > 5}
/>
<div className="line-container"></div>
</div>
);
})}
</>
);
};
export function ThreadRoute({ id }: { id?: string }) {
const params = useParams();
const resolvedId = id ?? params.id;
const link = parseNostrLink(resolvedId ?? "", NostrPrefix.Note);
return (
<ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
);
}
export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
const thread = useContext(ThreadContext);
const navigate = useNavigate();
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
const rootOptions = useMemo(
() => ({ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }),
[props.disableSpotlight],
);
const navigateThread = useCallback(
(e: TaggedNostrEvent) => {
thread.setCurrent(e.id);
// navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true });
},
[thread],
);
const parent = useMemo(() => {
if (thread.root) {
const currentThread = EventExt.extractThread(thread.root);
return (
currentThread?.replyTo?.value ??
currentThread?.root?.value ??
(currentThread?.root?.key === "a" && currentThread.root?.value)
);
}
}, [thread.root]);
function renderRoot(note: TaggedNostrEvent) {
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.id}
data={note}
options={rootOptions}
onClick={navigateThread}
threadChains={thread.chains}
waitUntilInView={false}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
function renderChain(from: u256): ReactNode {
if (!from || thread.chains.size === 0) {
return;
}
const replies = thread.chains.get(from);
if (replies && thread.current) {
return <Subthread active={thread.current} notes={replies} chains={thread.chains} onNavigate={navigateThread} />;
}
}
function goBack() {
if (parent) {
thread.setCurrent(parent);
} else if (props.onBack) {
props.onBack();
} else {
navigate(-1);
}
}
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
const debug = window.location.search.includes("debug=true");
return (
<>
{debug && (
<div className="main-content p xs">
<h1>Chains</h1>
<pre>
{JSON.stringify(
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
undefined,
" ",
)}
</pre>
<h1>Current</h1>
<pre>{JSON.stringify(thread.current)}</pre>
<h1>Root</h1>
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div>
)}
{parent && (
<div className="main-content p">
<BackButton onClick={goBack} text={parentText} />
</div>
)}
<div className="main-content">
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
</div>
</>
);
}
function getReplies(from: u256, chains?: Map<u256, Array<TaggedNostrEvent>>): Array<TaggedNostrEvent> {
if (!from || !chains) {
return [];
}
const replies = chains.get(from);
return replies ? replies : [];
}

View File

@ -0,0 +1,12 @@
interface DividerProps {
variant?: "regular" | "small";
}
export const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}></div>
</div>
);
};

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