Compare commits

..

163 Commits

Author SHA1 Message Date
e3b4be26ce chore(git): merge pull request #211 from issues/203-nsfw-redirect into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
Reviewed-on: #211
2025-01-30 18:17:05 +00:00
en
0019713bf9 fix(nsfw): show popup if visiting nsfw post, redirect on cancel to home
Closes #203
2025-01-30 19:13:07 +01:00
en
44d7f57f0a fix(nsfw): close will trigger as if clicking no 2025-01-30 19:08:54 +01:00
7331866479 chore(git): merge pull request#210 from issues/140-zap-split-ux into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
Reviewed-on: #210
2025-01-30 17:27:26 +00:00
en
c1b4dac5a3 feat(zaps): show profile image on qr if available 2025-01-30 18:21:55 +01:00
en
9287822c64 fix(zaps): zap split UX
Closes #140
2025-01-30 18:01:37 +01:00
1ef86470fc Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-30 15:47:52 +00:00
903cf30377 Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-30 15:44:00 +00:00
d99f2941cb Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-30 15:36:38 +00:00
88a1bdfdd3 Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-30 14:49:28 +00:00
f51dde697a Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 14:44:55 +00:00
8747633104 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-30 14:32:26 +00:00
c279e9ee87 Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 14:18:32 +00:00
7f3d54f10c Update src/assets/games/Games_Steam5.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-30 14:15:06 +00:00
c217ed15b8 chore(git): merge pull request #209 from fixes/adv-comments-30-1-25 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m9s
Reviewed-on: #209
2025-01-30 13:43:13 +00:00
en
1226c60917 refactor(comments): move style from react to css 2025-01-30 14:42:12 +01:00
en
94eb88bdd3 fix(comments): clear input on publish 2025-01-30 14:28:42 +01:00
en
bf18d61f1f fix(comments): publish and discovery interaction, add discovery to popup 2025-01-30 14:20:44 +01:00
en
a92d1da7ad fix(comments): move depth calc, parent and root fetch to hook, main post leads to root, add small spinners 2025-01-30 11:12:03 +01:00
3804644635 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-30 09:26:15 +00:00
en
9b9e97b40e fix(comments): comment out repost button 2025-01-30 09:44:17 +01:00
en
b03fa6e55d fix(comments): show new line in content p 2025-01-30 09:40:12 +01:00
en
8430a55beb fix(comments): cache first comments fetch 2025-01-30 09:34:34 +01:00
99c80855d2 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-29 21:43:34 +00:00
b918c875a4 chore(git): merge pull request #207 from feat/130-adv-comments into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
Reviewed-on: #207
2025-01-29 20:53:32 +00:00
cb7c10384a Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-29 20:34:52 +00:00
en
905b3ee5e4 feat(comments): add popup, types, and utils, split components 2025-01-29 21:24:44 +01:00
en
11f4281067 feat(ndk): use ndk nip07 signer 2025-01-29 21:24:44 +01:00
en
97b44a55f2 feat(reply): publish new reply with ndkevent, fetch kind 1 and 1111 2025-01-29 21:24:44 +01:00
612524741b Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m15s
2025-01-29 17:04:19 +00:00
8189149288 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
2025-01-29 15:41:47 +00:00
f51018befb Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-28 14:01:20 +00:00
e213a61f56 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:53:22 +00:00
0612c24dee Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
2025-01-28 13:46:40 +00:00
df21fa0346 Update src/styles/cardGames.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:38:51 +00:00
4d240554ce Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-28 13:30:17 +00:00
1d5550190b Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m13s
2025-01-28 09:55:15 +00:00
6dafda7071 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m10s
2025-01-28 01:09:00 +00:00
8dd73919f5 chore(git): merge pull request #206 from issues/205-zap-tipping-broken into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m8s
Reviewed-on: #206
2025-01-27 13:42:28 +00:00
en
670b981b05 fix(zap): add timeout and hide loading when done 2025-01-27 14:24:48 +01:00
en
b41676e4a9 fix(pubkey): handle error thrown by canceling pubkey fetch 2025-01-27 13:20:44 +01:00
87244dda32 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-27 12:03:48 +00:00
79020fbcde Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-27 12:01:16 +00:00
en
02897ea72a fix(wot): site and mine filter condition updated
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m6s
2025-01-27 12:28:33 +01:00
en
1bac85f6f0 fix(wot): site and mine filter condition updated
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m11s
Fixes #204
2025-01-27 12:23:45 +01:00
en
144c24b254 chore(deps): update ndk version
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-24 11:09:22 +01:00
dbbbba075b Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-23 22:47:03 +00:00
2dbad13c5d Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
2025-01-22 19:47:12 +00:00
4f27b4aafa Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-22 19:45:47 +00:00
15be873136 Update src/assets/games/Games_Steam3.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2025-01-22 19:44:25 +00:00
73e17c09d5 Update src/assets/games/Games_SteamManual.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m11s
2025-01-22 19:30:28 +00:00
ed348533ad chore(git): merge pull request #202 from extra/112-mods-fields into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Reviewed-on: #202
2025-01-22 16:51:04 +00:00
en
5a6327fb73 feat(mod): add permissions and details
Close #112
2025-01-22 17:47:38 +01:00
99d7dbe89d Update src/components/ModForm.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2025-01-21 20:40:40 +00:00
e11e6f1fb2 Update src/styles/downloads.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
2025-01-21 17:48:06 +00:00
83f15b4b69 Update src/pages/mod/index.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
2025-01-21 17:45:09 +00:00
enes
787231ce0d fix(download): reset on new fields
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
2025-01-21 17:36:50 +01:00
1e9c24e013 chore(git): merge pull request #201 from extra/196-mods-refactor into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 51s
Reviewed-on: #201
2025-01-21 16:26:50 +00:00
enes
d75e3508ea feat(download): add download details popup 2025-01-21 17:19:35 +01:00
enes
4bec281ea0 feat(download): add media url 2025-01-21 16:27:03 +01:00
enes
3f141ed58b feat(download): add title and remove show more links 2025-01-21 15:47:05 +01:00
beed4dabe0 chore(git): merge pull request #200 from extra/188-only-moderated-filter into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 50s
Reviewed-on: #200
2025-01-21 10:55:57 +00:00
enes
e5dd28c23c feat(filter): add only moderation filter to mods
Closes #188
2025-01-21 11:54:23 +01:00
09dda039da Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
2025-01-20 11:50:10 +00:00
f53eeeece1 Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-19 21:08:29 +00:00
0a2e6a327a Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
2025-01-17 21:31:21 +00:00
9d7c57224b Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
2025-01-17 21:03:05 +00:00
096c16f0f6 Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-17 20:50:05 +00:00
85116e0e9f Upload files to "src/assets/games"
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
2025-01-17 20:38:15 +00:00
enes
d00b142231 fix(download): add checkUrlForFile timeout
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
Fix #164
2025-01-17 12:51:52 +01:00
enes
32f35ebcca revert: scan link early return
Refs: fbde15e075d92bf4070b75f5decc554ca18db02e.
2025-01-17 12:49:34 +01:00
enes
fbde15e075 fix(download): show notice and return earily if missing malware scan linky
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
Fix #164
2025-01-17 12:29:01 +01:00
enes
599c29b4c4 fix(download): warn on same scan and url link
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Fix #164
2025-01-17 10:24:19 +01:00
enes
a9c5c3d18a build(download): remove unused variable
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-16 19:45:02 +01:00
enes
dc96783231 fix(download): remove reachable and fix typo
Some checks failed
Release to Staging / build_and_release (push) Failing after 26s
Fix #164
2025-01-16 19:42:52 +01:00
enes
18a9744f96 fix(download): scan link detection
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Fix #164
2025-01-16 19:19:34 +01:00
02c4cb52b1 chore(git): merge pull request #199 from extra/164-scan-notice into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Reviewed-on: #199
2025-01-16 17:18:04 +00:00
enes
f335640ec5 feat(download): add malware scan notice
Closes #164
2025-01-16 18:16:36 +01:00
595360c88c chore(git): merge pull request #198 from extra/161-download-link-notice into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Reviewed-on: #198
2025-01-16 14:03:32 +00:00
enes
3d7671c303 feat(download): show notice download url leads to another website
Closes #161
2025-01-16 15:01:44 +01:00
e85b33d95d chore(git): merge pull request #197 from extra/154-blocked-warning into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #197
2025-01-16 12:07:46 +00:00
enes
a835996db7 refactor(mutelist): no need to check admin specific list for isBlocked 2025-01-16 13:06:16 +01:00
enes
8c10a467be fix(router): revalited loaders on auth 2025-01-16 12:57:13 +01:00
enes
cdf23c7fac feat(mutelist): add post warnings to blog/mod
+ fix: block/unblock string
2025-01-16 12:56:46 +01:00
enes
177d2fb2ac fix(zap): hide split button if author has no ln address
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Fixes #183
2025-01-16 11:15:17 +01:00
425f4f9cd4 chore(git): merge pull request #195 from issues/190-missing-repost-tags into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
Reviewed-on: #195
2025-01-15 19:22:06 +00:00
enes
d079c1a564 fix(home): add repost tag to latest mods 2025-01-15 20:20:07 +01:00
enes
e0394ab0fd feat: add repostList hook 2025-01-15 20:19:02 +01:00
df5ebdb37f chore(git): merge pull request #194 from issues/179-only-logged-in-report into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
Reviewed-on: #194
2025-01-15 18:33:25 +00:00
enes
4832c46548 fix(report): available only to logged in users
Closes #179
2025-01-15 19:32:06 +01:00
a97159119d chore(git): merge pull request #193 from issues/183-ln-button into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 59s
Reviewed-on: #193
2025-01-15 18:12:08 +00:00
enes
0f2af47087 fix(profile): check for empty strings for ln
Closes #183
2025-01-15 19:10:48 +01:00
138de5a2ae chore(git): merge pull request #192 from 182-old-mods-edit into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #192
2025-01-15 16:07:43 +00:00
enes
9931f4ec0d feat(mods): editor error handling 2025-01-15 17:05:35 +01:00
enes
094b7349b3 feat(editor): add diffsourcePlugin 2025-01-15 17:05:12 +01:00
enes
3f80f9e0ce refactor: remove unused package 2025-01-15 17:04:09 +01:00
2949444c8a chore(git): merge pull request #191 from 186-try-again-mod-publish into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
Reviewed-on: #191
2025-01-15 12:24:36 +00:00
enes
ce27515bfe feat: spinner with timer 2025-01-14 17:48:15 +01:00
enes
5d479102d4 feat: mod submission try again 2025-01-14 17:23:47 +01:00
enes
a247f05f6e refactor: publish then catch to try catch 2025-01-14 17:16:22 +01:00
enes
dddabbc1d1 feat(errors): timeout error and set prototype 2025-01-09 17:20:59 +01:00
df27451c46 chore(git): merge pull request #187 from 166-caching-fields into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
Reviewed-on: #187
2025-01-09 13:45:45 +00:00
enes
17e9cad9e3 refactor(mods): use lodash clone 2025-01-09 14:30:54 +01:00
enes
5fad718356 feat(cache): clear cache on succesful publish 2025-01-09 14:17:12 +01:00
enes
c95af90b28 fix(mod): set original author field to optional 2025-01-09 14:04:45 +01:00
enes
60773ec446 feat(cache): add blog cache, blog to controlled inputs 2025-01-09 13:21:49 +01:00
enes
30a87cc347 feat(cache): add mod cache 2025-01-09 13:20:44 +01:00
enes
b3ade8e1d2 style(categories): prettier formatting 2025-01-09 13:20:11 +01:00
enes
b60659eebf feat(cache): add simple localcache hook 2025-01-09 13:19:17 +01:00
enes
f214d66799 refactor(storage): util func moved 2025-01-09 13:18:20 +01:00
enes
ed3585f9c8 fix(mod): reset form 2025-01-08 13:59:35 +01:00
a278800025 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2025-01-08 00:37:04 +00:00
2224403742 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-08 00:32:29 +00:00
649adc609b Update src/assets/games/Games_Steam2.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2025-01-07 20:18:50 +00:00
397ab457e4 Update src/assets/games/Games_Steam1.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m0s
2025-01-07 20:15:09 +00:00
7854b5480c Update src/assets/games/Games_Other.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-07 20:06:28 +00:00
cda8e1c210 chore(git): merge pull request #185 from fixes-1-7 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Reviewed-on: #185
2025-01-07 20:02:56 +00:00
enes
33b8565051 feat(image): add spinner while uploading 2025-01-07 21:01:11 +01:00
enes
d4d7dde1ab fix(mod): redirect on edit if user is not original author 2025-01-07 20:40:59 +01:00
6170050070 chore(git): merge pull request #184 from 106-direct-image-upload into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s
Reviewed-on: #184
2025-01-07 18:33:55 +00:00
enes
8205d4ac3e refactor: remove comment 2025-01-07 19:29:16 +01:00
enes
080b231f5c chore(git): merge staging into 106-direct-image-upload 2025-01-07 19:25:40 +01:00
e
8f97161eb2 chore: code cleanup 2025-01-07 16:39:23 +01:00
e
7244591d34 fix(image): bad image url input field name 2025-01-07 14:31:19 +01:00
enes
0026f4d751 feat(image): multiple files upload 2025-01-07 12:40:27 +01:00
enes
9fd1aca99c feat(image): use image upload field in blog 2025-01-07 10:04:37 +01:00
enes
4c410be9ba feat(image): use image upload field 2025-01-07 09:49:02 +01:00
enes
b33015cbaf feat(image): add direct image upload components 2025-01-07 09:48:30 +01:00
enes
0b2d488bbe feat(image): add controller, media services and error handling 2025-01-07 09:46:28 +01:00
enes
e3aab5a5dc build: bump to es2022 2025-01-07 09:43:46 +01:00
enes
31cd625886 feat(image): add dropzone package 2025-01-07 09:41:20 +01:00
efa16433e8 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-05 20:32:44 +00:00
2dccadd670 Update src/pages/about.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-05 20:26:19 +00:00
331ac285ce Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-05 14:35:16 +00:00
cff9bbd8d0 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2025-01-05 13:16:39 +00:00
4527c1c154 Update src/styles/post.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
2025-01-03 16:11:25 +00:00
35176302e5 Update src/assets/games/Games_Steam4.csv
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2025-01-03 11:26:38 +00:00
08884cc066 Update src/layout/header.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m3s
2025-01-02 23:25:40 +00:00
50d982a3a8 Update src/components/ModForm.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 57s
2025-01-02 21:43:09 +00:00
enes
e15307be3b fix(mod): loading mod edge case
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Close #175 - Add timeout to requests and try again button
2025-01-01 16:16:39 +01:00
c6c2013f1e Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2025-01-01 12:19:03 +00:00
aa9f1015e1 Update src/styles/styles.css
All checks were successful
Release to Staging / build_and_release (push) Successful in 58s
2025-01-01 12:17:25 +00:00
a46aaa9e55 Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
2024-12-31 15:50:32 +00:00
50f9800935 chore(git): merge pull request #178 from fixes-12-26 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 56s
Reviewed-on: #178
2024-12-27 10:22:58 +00:00
enes
5449591b6e fix: blogs loading for reported nprofile 2024-12-26 17:21:19 +01:00
enes
1c4a9c8586 fix: failing links in footer, backup and supporters pages prep
Partially #142
2024-12-26 17:20:21 +01:00
enes
ad68ba8e84 fix: add fallback for usersPubkey in loaders 2024-12-26 16:46:40 +01:00
enes
ad3d069ad5 refactor: deps cleanup 2024-12-26 16:42:11 +01:00
enes
4bf9787660 refactor: remove user metadata console errors 2024-12-26 16:40:40 +01:00
enes
037b81c49e fix(games): add no games found in search
Closes #141
2024-12-26 16:39:09 +01:00
7a5639b8cf Upload files to "src/assets/img"
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-12-24 19:36:11 +00:00
enes
2440620328 fix(viewer): remove double sanitize, fix yt directive
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
2024-12-24 20:26:38 +01:00
73cec02ee5 chore(git): merge pull request #176 from feedback-11-24 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
Reviewed-on: #176
2024-12-24 18:38:20 +00:00
enes
130be2567d fix(editor): feedback updates 2024-12-24 19:36:58 +01:00
fdbb64d360 chore(git): merge pull request #174 from 170-editor-update into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m1s
Reviewed-on: #174
2024-12-24 16:13:48 +00:00
0b7d88a18c Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-12-22 23:42:08 +00:00
dd081ed1a2 Update src/assets/categories/categories.json
Some checks failed
Release to Staging / build_and_release (push) Failing after 24s
2024-12-22 23:35:30 +00:00
a93c113701 Update src/assets/categories/categories.json
Some checks failed
Release to Staging / build_and_release (push) Failing after 28s
2024-12-22 23:25:09 +00:00
3ee868e613 Update src/assets/categories/categories.json
All checks were successful
Release to Staging / build_and_release (push) Successful in 54s
2024-12-22 23:12:46 +00:00
c448e3be73 Update src/components/Filters/CategoryFilterPopup.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 53s
2024-12-22 22:46:04 +00:00
d823d3f007 Update src/components/Filters/CategoryFilterPopup.tsx
All checks were successful
Release to Staging / build_and_release (push) Successful in 49s
2024-12-22 22:42:58 +00:00
c973bdd436 adjusted placeholder text
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
2024-12-22 22:35:46 +00:00
777cd2a7c7 adjusted placeholder text
Some checks failed
Release to Staging / build_and_release (push) Failing after 20s
2024-12-22 22:33:36 +00:00
104 changed files with 5612 additions and 58576 deletions

View File

@ -1,10 +1,10 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
env: { browser: true, es2022: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
@ -12,7 +12,7 @@ module.exports = {
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
{ allowConstantExport: true }
]
}
}

178
package-lock.json generated
View File

@ -10,8 +10,8 @@
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@nostr-dev-kit/ndk": "2.11.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
"@reduxjs/toolkit": "2.2.6",
"@types/react-helmet": "^6.1.11",
"axios": "^1.7.9",
@ -32,13 +32,13 @@
"react": "^18.3.1",
"react-countdown": "2.3.5",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0",
"react-redux": "9.1.2",
"react-router-dom": "^6.24.1",
"react-toastify": "10.0.5",
"react-window": "1.8.10",
"swiper": "11.1.11",
"turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},
@ -2057,12 +2057,6 @@
"react-dom": ">= 18 || >= 19"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
@ -2140,19 +2134,19 @@
}
},
"node_modules/@nostr-dev-kit/ndk": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz",
"integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz",
"integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
"@scure/base": "^1.1.1",
"debug": "^4.3.4",
"light-bolt11-decoder": "^3.0.0",
"node-fetch": "^3.3.1",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@noble/secp256k1": "^2.1.0",
"@scure/base": "^1.1.9",
"debug": "^4.3.6",
"light-bolt11-decoder": "^3.2.0",
"nostr-tools": "^2.7.1",
"tseep": "^1.1.1",
"tseep": "^1.2.2",
"typescript-lru-cache": "^2.0.0",
"utf8-buffer": "^1.0.0",
"websocket-polyfill": "^0.0.3"
@ -2162,13 +2156,14 @@
}
},
"node_modules/@nostr-dev-kit/ndk-cache-dexie": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz",
"integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==",
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.9.tgz",
"integrity": "sha512-SZ5FjON0QPekiC7oW9Hy3JQxG0Oxxtud9LBa1q/A49JV/Qppv1x37nFHxi0XLxEbDgFTNYbaN27Zjfp2NPem2g==",
"license": "MIT",
"dependencies": {
"@nostr-dev-kit/ndk": "2.10.0",
"debug": "^4.3.4",
"dexie": "^4.0.2",
"@nostr-dev-kit/ndk": "2.11.0",
"debug": "^4.3.7",
"dexie": "^4.0.8",
"nostr-tools": "^2.4.0",
"typescript-lru-cache": "^2.0.0"
}
@ -3937,6 +3932,15 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/attributes-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/attributes-parser/-/attributes-parser-2.2.3.tgz",
@ -4359,14 +4363,6 @@
"node": ">=0.12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"engines": {
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@ -5034,28 +5030,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -5073,6 +5047,18 @@
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -5161,17 +5147,6 @@
"node": ">=0.4.x"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -6808,41 +6783,6 @@
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz",
@ -7343,6 +7283,23 @@
"react": "^18.3.1"
}
},
"node_modules/react-dropzone": {
"version": "14.3.5",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz",
"integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-error-boundary": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
@ -7998,15 +7955,6 @@
"resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz",
"integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw=="
},
"node_modules/turndown": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz",
"integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
@ -8380,14 +8328,6 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"engines": {
"node": ">= 8"
}
},
"node_modules/webln": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/webln/-/webln-0.3.2.tgz",

View File

@ -12,8 +12,8 @@
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@nostr-dev-kit/ndk": "2.11.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
"@reduxjs/toolkit": "2.2.6",
"@types/react-helmet": "^6.1.11",
"axios": "^1.7.9",
@ -34,13 +34,13 @@
"react": "^18.3.1",
"react-countdown": "2.3.5",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0",
"react-redux": "9.1.2",
"react-router-dom": "^6.24.1",
"react-toastify": "10.0.5",
"react-window": "1.8.10",
"swiper": "11.1.11",
"turndown": "^7.2.0",
"uuid": "10.0.0",
"webln": "0.3.2"
},

View File

@ -1,11 +1,12 @@
import { RouterProvider } from 'react-router-dom'
import { useEffect } from 'react'
import { routerWithNdkContext } from 'routes'
import { useEffect, useMemo } from 'react'
import { routerWithNdkContext as routerWithState } from 'routes'
import { useNDKContext } from 'hooks'
import './styles/styles.css'
function App() {
const ndkContext = useNDKContext()
const router = useMemo(() => routerWithState(ndkContext), [ndkContext])
useEffect(() => {
// Find the element with id 'root'
@ -24,7 +25,7 @@ function App() {
}
}, [])
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
return <RouterProvider router={router} />
}
export default App

View File

@ -1,27 +1,19 @@
[
{ "name": "gameplay ", "sub": ["difficulty"] },
{ "name": "input", "sub": ["key mapping", "macro"] },
{
"name": "audio",
"sub": [
{ "name": "music", "sub": ["background", "ambient"] },
{
"name": "sound effects",
"sub": ["footsteps", "weapons"]
},
"voice"
]
"name": "visual",
"sub": ["textures", "lighting", "character models", "environment models"]
},
{ "name": "audio", "sub": ["sfx", "music", "voice"] },
{ "name": "user interface", "sub": ["hud", "menu"] },
{
"name": "graphical",
"sub": [
{
"name": "textures",
"sub": ["highres textures", "lowres textures"]
},
"models",
"shaders"
]
"name": "quality of life",
"sub": ["bug fixes", "performance", "accessibility"]
},
{ "name": "user interface", "sub": ["hud", "menus", "icons"] },
{ "name": "gameplay", "sub": ["mechanics", "balance", "ai"] },
"bugfixes"
"total conversions",
"translation",
"multiplayer",
"clothing",
"mod manager"
]

View File

@ -6,4 +6,9 @@ Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
The Elder Scrolls: Skyblivion,,https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
Stellar Blade,,https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
Bayonetta 2,,https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
Grand Theft Auto: Vice City,,https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
Alan Wake 2,,https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
1 Game Name 16 by 9 image Boxart image
6 Genshin Impact https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
7 Zenless Zone Zero https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
8 Ananta https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
9 Bloodborne https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
10 The Elder Scrolls: Skyblivion https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
11 Stellar Blade https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
12 Bayonetta 2 https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
13 Grand Theft Auto: Vice City https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
14 Alan Wake 2 https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,10 @@
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
FINAL FANTASY VII REBIRTH,,https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
NINJA GAIDEN 2 Black,,https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
Rise of the Ronin,,https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
NINJA GAIDEN 4,,https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
Batman Arkham Asylum,,https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
Kingdom Hearts,,https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
Kingdom Hearts II,,https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg
1 Game Name 16 by 9 image Boxart image
2 Marvel's Spider-Man 2 https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
3 S.T.A.L.K.E.R. 2: Heart of Chornobyl https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
4 FINAL FANTASY VII REBIRTH https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
5 NINJA GAIDEN 2 Black https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
6 Rise of the Ronin https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
7 NINJA GAIDEN 4 https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
8 Batman Arkham Asylum https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
9 Kingdom Hearts https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
10 Kingdom Hearts II https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -0,0 +1,113 @@
import { createPortal } from 'react-dom'
import { DownloadUrl } from '../types'
export const DownloadDetailsPopup = ({
title,
url,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote,
mediaUrl,
handleClose
}: DownloadUrl & {
handleClose: () => void
}) => {
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{title || 'Authentication Details'}</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<div className='pUMCB_ZapsInside'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTable'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Download URL</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{url}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>SHA-256 hash</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{hash}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Signature from</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{signatureKey}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Scan</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{malwareScanLink}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Mod Version</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{modVersion}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Note</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{customNote}</p>
</div>
</div>
{typeof mediaUrl !== 'undefined' && mediaUrl !== '' && (
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Media</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<img
src={mediaUrl}
className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol_Img'
alt=''
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@ -154,7 +154,7 @@ export const CategoryFilterPopup = ({
Choose categories...
</label>
<p className='labelDescriptionMain'>
This is description for an input and how to use search here
Choose one or more pre-definied or custom categories to filter out mods with.
</p>
</div>
<input
@ -173,7 +173,7 @@ export const CategoryFilterPopup = ({
>
Custom categories
</label>
<p className='labelDescriptionMain'>Maybe</p>
<p className='labelDescriptionMain'>Here&apos;s where your custom categories appear (You can add them in the above field. Example &gt; banana &gt; seed)</p>
</div>
<div
className='inputMain'
@ -230,7 +230,7 @@ export const CategoryFilterPopup = ({
>
Categories
</label>
<p className='labelDescriptionMain'>Maybe</p>
<p className='labelDescriptionMain'>Here&apos;s where you select any of the pre-defined categories</p>
</div>
<div
className='inputMain'

View File

@ -48,10 +48,14 @@ export const ModFilter = React.memo(
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
if (item === ModeratedFilter.Only_Blocked && !isAdmin) {
return null
}
if (item === ModeratedFilter.Unmoderated_Fully) {
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author

View File

@ -0,0 +1,14 @@
type InputErrorProps = {
message: string
}
export const InputError = ({ message }: InputErrorProps) => {
if (!message) return null
return (
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
)
}

View File

@ -0,0 +1,11 @@
.spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
pointer-events: none;
}

View File

@ -0,0 +1,126 @@
import { useDropzone } from 'react-dropzone'
import React, { useCallback, useMemo, useState } from 'react'
import {
MediaOption,
MEDIA_OPTIONS,
ImageController,
MEDIA_DROPZONE_OPTIONS
} from '../../controllers'
import { errorFeedback } from '../../types'
import { MediaInputPopover } from './MediaInputPopover'
import { Spinner } from 'components/Spinner'
import styles from './ImageUpload.module.scss'
export interface ImageUploadProps {
multiple?: boolean | undefined
onChange: (values: string[]) => void
}
export const ImageUpload = React.memo(
({ multiple = false, onChange }: ImageUploadProps) => {
const [isLoading, setIsLoading] = useState(false)
const [mediaOption, setMediaOption] = useState<MediaOption>(
MEDIA_OPTIONS[0]
)
const handleOptionChange = useCallback(
(mo: MediaOption) => () => {
setMediaOption(mo)
},
[]
)
const handleUpload = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length) {
try {
setIsLoading(true)
const imageController = new ImageController(mediaOption)
const urls: string[] = []
for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i]
urls.push(await imageController.post(file))
}
onChange(urls)
} catch (error) {
errorFeedback(error)
} finally {
setIsLoading(false)
}
}
},
[mediaOption, onChange]
)
const {
getRootProps,
getInputProps,
isDragActive,
acceptedFiles,
isFileDialogActive,
isDragAccept,
isDragReject,
fileRejections
} = useDropzone({
...MEDIA_DROPZONE_OPTIONS,
onDrop: handleUpload,
multiple: multiple
})
const dropzoneLabel = useMemo(
() =>
isFileDialogActive
? 'Select files in dialog'
: isDragActive
? isDragAccept
? 'Drop the files here...'
: isDragReject
? 'Drop the files here (one more more unsupported types)...'
: 'Drop the files here...'
: 'Click or drag files here',
[isDragAccept, isDragActive, isDragReject, isFileDialogActive]
)
return (
<div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
<MediaInputPopover
acceptedFiles={acceptedFiles}
fileRejections={fileRejections}
/>
<div className='uploadBoxMainInside' {...getRootProps()} tabIndex={-1}>
<input id='featuredImageUrl-upload' {...getInputProps()} />
<span>{dropzoneLabel}</span>
<div
className='FiltersMainElement'
onClick={(e) => e.stopPropagation()}
>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
Image Host: {mediaOption.name}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{MEDIA_OPTIONS.map((mo) => {
return (
<div
key={mo.host}
onClick={handleOptionChange(mo)}
className='dropdown-item dropdownMainMenuItem'
>
{mo.name}
</div>
)
})}
</div>
</div>
</div>
{isLoading && (
<div className={styles.spinner}>
<Spinner />
</div>
)}
</div>
</div>
)
}
)

View File

@ -0,0 +1,14 @@
.accordion-button::after {
position: absolute;
right: 0.75rem;
color: rgba(255, 255, 255, 0.5) !important;
top: unset !important;
bottom: unset !important;
}
.accordion-body > * {
margin-top: 10px;
}
.accordion-item + .accordion-item {
margin-top: 10px;
}

View File

@ -0,0 +1,64 @@
import { FileError } from 'react-dropzone'
import styles from './MediaInputError.module.scss'
type MediaInputErrorProps = {
rootId: string
index: number
message: string
errors?: readonly FileError[] | undefined
}
export const MediaInputError = ({
rootId,
index,
message,
errors
}: MediaInputErrorProps) => {
if (!message) return null
return (
<div className={['accordion-item', styles['accordion-item']].join(' ')}>
<h2 className='accordion-header' role='tab'>
<button
className={[
'accordion-button collapsed',
styles['accordion-button']
].join(' ')}
type='button'
data-bs-toggle='collapse'
data-bs-target={`#${rootId} .item-${index}`}
aria-expanded='false'
aria-controls={`${rootId} .item-${index}`}
>
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
</button>
</h2>
{errors && (
<div
className={`accordion-collapse collapse item-${index}`}
role='tabpanel'
data-bs-parent={`#${rootId}`}
>
<div
className={['accordion-body', styles['accordion-body']].join(' ')}
>
{errors.map((e) => {
return typeof e === 'string' ? (
<div className='errorMain' key={e}>
{e}
</div>
) : (
<div className='errorMain' key={e.code}>
{e.message}
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,45 @@
.popover {
border-radius: 15px;
box-shadow: 0 0 16px 0px rgb(0 0 0 / 15%);
background: #232323;
z-index: 2;
}
.content {
max-height: 500px;
overflow-y: auto;
padding: 25px;
> *:not(:first-child) {
margin-top: 10px;
}
}
.trigger {
position: absolute;
top: 25px;
right: 25px;
color: rgba(255, 255, 255, 0.5);
}
.mediaInputError {
--bs-accordion-color: unset;
--bs-accordion-bg: unset;
--bs-accordion-transition: unset;
--bs-accordion-border-color: unset;
--bs-accordion-border-width: unset;
--bs-accordion-border-radius: unset;
--bs-accordion-inner-border-radius: unset;
--bs-accordion-btn-padding-x: unset;
--bs-accordion-btn-padding-y: unset;
--bs-accordion-btn-color: unset;
--bs-accordion-btn-bg: unset;
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-focus-border-color: unset;
--bs-accordion-btn-focus-box-shadow: unset;
--bs-accordion-body-padding-x: unset;
--bs-accordion-body-padding-y: unset;
--bs-accordion-active-color: unset;
--bs-accordion-active-bg: unset;
}

View File

@ -0,0 +1,108 @@
import * as Popover from '@radix-ui/react-popover'
import { v4 as uuidv4 } from 'uuid'
import { useMemo } from 'react'
import { FileRejection, FileWithPath } from 'react-dropzone'
import { MediaInputError } from './MediaInputError'
import { InputSuccess } from './Success'
import styles from './MediaInputPopover.module.scss'
interface MediaInputPopoverProps {
acceptedFiles: readonly FileWithPath[]
fileRejections: readonly FileRejection[]
}
export const MediaInputPopover = ({
acceptedFiles,
fileRejections
}: MediaInputPopoverProps) => {
const uuid = useMemo(() => uuidv4(), [])
const acceptedFileItems = useMemo(
() =>
acceptedFiles.map((file) => (
<InputSuccess
key={file.path}
message={`${file.path} - ${file.size} bytes`}
/>
)),
[acceptedFiles]
)
const fileRejectionItems = useMemo(() => {
const id = `errors-${uuid}`
return (
<div
className={`accordion accordion-flush ${styles.mediaInputError}`}
role='tablist'
id={id}
>
{fileRejections.map(({ file, errors }, index) => (
<MediaInputError
rootId={id}
index={index}
key={file.path}
message={`${file.path} - ${file.size} bytes`}
errors={errors}
/>
))}
</div>
)
}, [fileRejections, uuid])
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
return (
<Popover.Root>
<Popover.Trigger asChild>
<div className={styles.trigger}>
{acceptedFiles.length > 0 ? (
<svg
width='1.5em'
height='1.5em'
fill='currentColor'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 576 512'
>
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm211.3-43.3c-6.2-6.2-16.4-6.2-22.6 0L416 385.4l-28.7-28.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l40 40c6.2 6.2 16.4 6.2 22.6 0l72-72c6.2-6.2 6.2-16.4 0-22.6z' />
</svg>
) : (
<svg
width='1.5em'
height='1.5em'
fill='tomato'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 576 512'
>
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zm48 96a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm0 240a24 24 0 1 0 0-48 24 24 0 1 0 0 48zm0-192c-8.8 0-16 7.2-16 16l0 80c0 8.8 7.2 16 16 16s16-7.2 16-16l0-80c0-8.8-7.2-16-16-16z' />
</svg>
)}
</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className={styles.popover} sideOffset={5}>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Selected files</h3>
</div>
<Popover.Close asChild aria-label='Close'>
<div className='popUpMainCardTopClose'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</Popover.Close>
</div>
<div className={styles.content}>
{acceptedFileItems}
{fileRejectionItems}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}

View File

@ -0,0 +1,14 @@
type InputSuccessProps = {
message: string
}
export const InputSuccess = ({ message }: InputSuccessProps) => {
if (!message) return null
return (
<div className='successMain'>
<div className='successMainColor'></div>
<p className='successMainText'>{message}</p>
</div>
)
}

View File

@ -1,5 +1,7 @@
import React from 'react'
import '../styles/styles.css'
import React, { useCallback } from 'react'
import { InputError } from './Error'
import { ImageUpload } from './ImageUpload'
import '../../styles/styles.css'
interface InputFieldProps {
label: string | React.ReactElement
@ -60,21 +62,6 @@ export const InputField = React.memo(
}
)
type InputErrorProps = {
message: string
}
export const InputError = ({ message }: InputErrorProps) => {
if (!message) return null
return (
<div className='errorMain'>
<div className='errorMainColor'></div>
<p className='errorMainText'>{message}</p>
</div>
)
}
interface CheckboxFieldProps {
label: string
name: string
@ -157,3 +144,63 @@ export const CheckboxFieldUncontrolled = ({
<input type='checkbox' className='CheckboxMain' {...rest} />
</div>
)
interface InputFieldWithImageUploadProps {
label: string | React.ReactElement
description?: string
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onInputChange: (name: string, value: string) => void
}
export const InputFieldWithImageUpload = React.memo(
({
label,
description,
placeholder,
name,
inputMode,
value,
error,
onInputChange
}: InputFieldWithImageUploadProps) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onInputChange(name, e.currentTarget.value)
},
[name, onInputChange]
)
const handleFileChange = useCallback(
(values: string[]) => {
onInputChange(name, values[0])
},
[name, onInputChange]
)
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{typeof description !== 'undefined' && (
<p className='labelDescriptionMain'>{description}</p>
)}
<ImageUpload onChange={handleFileChange} />
<input
type='text'
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
{error && <InputError message={error} />}
</div>
)
}
)

View File

@ -1,3 +1,4 @@
import { Dots } from 'components/Spinner'
import { ZapSplit } from 'components/Zap'
import {
useAppSelector,
@ -8,7 +9,7 @@ import {
import { useState } from 'react'
import { toast } from 'react-toastify'
import { Addressable } from 'types'
import { abbreviateNumber } from 'utils'
import { abbreviateNumber, log, LogType } from 'utils'
type ZapProps = {
addressable: Addressable
@ -16,15 +17,25 @@ type ZapProps = {
export const Zap = ({ addressable }: ZapProps) => {
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isAvailable, setIsAvailable] = useState(false)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const { getTotalZapAmount } = useNDKContext()
const { getTotalZapAmount, findMetadata } = useNDKContext()
useBodyScrollDisable(isOpen)
useDidMount(() => {
findMetadata(addressable.author)
.then((res) => {
setIsAvailable(typeof res?.lud16 !== 'undefined' && res.lud16 !== '')
})
.catch((err) => {
log(true, LogType.Error, err.message || err)
})
getTotalZapAmount(
addressable.author,
addressable.id,
@ -38,8 +49,14 @@ export const Zap = ({ addressable }: ZapProps) => {
.catch((err) => {
toast.error(err.message || err)
})
.finally(() => {
setIsLoading(false)
})
})
// Hide button if the author hasn't set lud16
if (!isAvailable) return null
return (
<>
<div
@ -47,7 +64,7 @@ export const Zap = ({ addressable }: ZapProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
}`}
onClick={() => setIsOpen(true)}
onClick={isLoading ? undefined : () => setIsOpen(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -62,7 +79,7 @@ export const Zap = ({ addressable }: ZapProps) => {
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(totalZappedAmount)}
{isLoading ? <Dots /> : abbreviateNumber(totalZappedAmount)}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>

View File

@ -1,3 +1,4 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useNavigation } from 'react-router-dom'
import styles from '../../styles/loadingSpinner.module.scss'
@ -5,9 +6,7 @@ interface Props {
desc: string
}
export const LoadingSpinner = (props: Props) => {
const { desc } = props
export const LoadingSpinner = ({ desc }: Props) => {
return (
<div className={styles.loadingSpinnerOverlay}>
<div className={styles.loadingSpinnerContainer}>
@ -28,3 +27,51 @@ export const RouterLoadingSpinner = () => {
return <LoadingSpinner desc={`${desc}...`} />
}
interface TimerLoadingSpinner {
timeoutMs?: number
countdownMs?: number
}
export const TimerLoadingSpinner = ({
timeoutMs = 10000,
countdownMs = 30000,
children
}: PropsWithChildren<TimerLoadingSpinner>) => {
const [show, setShow] = useState(false)
const [timer, setTimer] = useState(
Math.floor((countdownMs - timeoutMs) / 1000)
)
const startTime = useMemo(() => Date.now(), [])
useEffect(() => {
let interval: number
const timeout = window.setTimeout(() => {
setShow(true)
interval = window.setInterval(() => {
const time = Date.now() - startTime
const diff = Math.max(0, countdownMs - time)
setTimer(Math.floor(diff / 1000))
}, 1000)
}, timeoutMs)
return () => {
clearTimeout(timeout)
clearInterval(interval)
}
}, [countdownMs, startTime, timeoutMs])
return (
<div className={styles.loadingSpinnerOverlay}>
<div className={styles.loadingSpinnerContainer}>
<div className={styles.loadingSpinner}></div>
{children}
{show && (
<>
<div>You can try again in {timer}s...</div>
</>
)}
</div>
</div>
)
}

View File

@ -4,6 +4,8 @@ import {
codeBlockPlugin,
CodeToggle,
CreateLink,
diffSourcePlugin,
DiffSourceToggleWrapper,
directivesPlugin,
headingsPlugin,
imagePlugin,
@ -68,34 +70,42 @@ export const Editor = React.memo(
() => [
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<DiffSourceToggleWrapper
children={
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<CreateLink />
<InsertImage />
<YouTubeButton />
<CreateLink />
<InsertImage />
<YouTubeButton />
<Separator />
<Separator />
<InsertTable />
<InsertThematicBreak />
<InsertTable />
<InsertThematicBreak />
<Separator />
<InsertCodeBlock />
</>
<Separator />
<InsertCodeBlock />
</>
}
/>
)
}),
headingsPlugin(),
diffSourcePlugin({
viewMode: 'rich-text',
diffMarkdown: markdown
}),
quotePlugin(),
imagePlugin({
ImageDialog: ImageDialog
@ -118,6 +128,7 @@ export const Editor = React.memo(
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)

View File

@ -9,12 +9,21 @@ export const youtubeDirective: DirectiveConfig = {
//::youtube{#<VIDEO_ID>}
let vid: string = ''
if (token.attrs && token.meta.name === 'youtube') {
for (const attr in token.attrs) {
if (
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
attr.startsWith('#')
) {
vid = attr.replace('#', '')
if (token.attrs.id) {
vid = token.attrs.id as string // Get the video `id` attribute (common id style)
} else if (token.attrs.vid) {
vid = token.attrs.vid as string // Check for the `vid` attribute (youtube directive attribute style)
} else {
// Fallback for id
// In case that video starts with the number it will not be recongizned as an id
// We have to manually fetch it
for (const attr in token.attrs) {
if (
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
attr.startsWith('#')
) {
vid = attr.replace('#', '')
}
}
}
}

View File

@ -18,18 +18,29 @@ import { useGames } from '../hooks'
import '../styles/styles.css'
import {
DownloadUrl,
FormErrors,
ModFormState,
ModPageLoaderResult
ModPageLoaderResult,
ModPermissions,
MODPERMISSIONS_CONF,
MODPERMISSIONS_DESC,
SubmitModActionResult
} from '../types'
import { initializeFormState } from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs'
import {
initializeFormState,
log,
LogType,
MOD_DRAFT_CACHE_KEY
} from '../utils'
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
import { OriginalAuthor } from './OriginalAuthor'
import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor'
import TurndownService from 'turndown'
import DOMPurify from 'dompurify'
import { MEDIA_OPTIONS } from 'controllers'
import { InputError } from './Inputs/Error'
import { ImageUpload } from './Inputs/ImageUpload'
import { useLocalCache } from 'hooks/useLocalCache'
import { toast } from 'react-toastify'
interface GameOption {
value: string
@ -39,19 +50,46 @@ interface GameOption {
export const ModForm = () => {
const data = useLoaderData() as ModPageLoaderResult
const mod = data?.mod
const formErrors = useActionData() as FormErrors
const actionData = useActionData() as SubmitModActionResult
const formErrors = useMemo(
() => (actionData?.type === 'validation' ? actionData.error : undefined),
[actionData]
)
const navigation = useNavigation()
const submit = useSubmit()
const games = useGames()
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
// Enable cache for the new mod
const isEditing = typeof mod !== 'undefined'
const [cache, setCache, clearCache] =
useLocalCache<ModFormState>(MOD_DRAFT_CACHE_KEY)
const [formState, setFormState] = useState<ModFormState>(
initializeFormState(mod)
isEditing ? initializeFormState(mod) : cache ? cache : initializeFormState()
)
// Enable backwards compatibility with the mods that used html
const body = useMemo(() => {
// Replace the most problematic HTML tags (<br>)
const fixed = formState.body.replaceAll(/<br>/g, '\r\n')
return fixed
}, [formState.body])
useEffect(() => {
if (!isEditing) {
const newCache = _.cloneDeep(formState)
// Remove aTag, dTag and published_at from cache
// These are used for editing and try again timeout
newCache.aTag = ''
newCache.dTag = ''
newCache.published_at = 0
setCache(newCache)
}
}, [formState, isEditing, setCache])
const editorRef = useRef<EditorRef>(null)
const sanitized = DOMPurify.sanitize(formState.body)
const turndown = new TurndownService()
turndown.keep(['sup', 'sub'])
const markdown = turndown.turndown(sanitized)
useEffect(() => {
const options = games.map((game) => ({
@ -79,6 +117,13 @@ export const ModForm = () => {
[]
)
const handleRadioChange = useCallback((name: string, value: boolean) => {
setFormState((prevState) => ({
...prevState,
[name]: value
}))
}, [])
const addScreenshotUrl = useCallback(() => {
setFormState((prevState) => ({
...prevState,
@ -146,47 +191,81 @@ export const ModForm = () => {
},
[]
)
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
useEffect(() => {
const isTimeout = actionData?.type === 'timeout'
setShowTryAgainPopup(isTimeout)
if (isTimeout) {
setFormState((prev) => ({
...prev,
aTag: actionData.data.aTag,
dTag: actionData.data.dTag,
published_at: actionData.data.published_at
}))
}
}, [actionData])
const handleTryAgainConfirm = useCallback(
(confirm: boolean) => {
setShowTryAgainPopup(false)
// Cancel if not confirmed
if (!confirm) return
submit(JSON.stringify(formState), {
method: isEditing ? 'put' : 'post',
encType: 'application/json'
})
},
[formState, isEditing, submit]
)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => {
const handleReset = useCallback(() => {
setShowConfirmPopup(true)
}
const handleResetConfirm = (confirm: boolean) => {
setShowConfirmPopup(false)
}, [])
const handleResetConfirm = useCallback(
(confirm: boolean) => {
setShowConfirmPopup(false)
// Cancel if not confirmed
if (!confirm) return
// Cancel if not confirmed
if (!confirm) return
// Editing
if (mod) {
const initial = initializeFormState(mod)
// Reset fields to the initial or original existing data
const initialState = initializeFormState(mod)
// Reset editor
editorRef.current?.setMarkdown(initial.body)
editorRef.current?.setMarkdown(initialState.body)
setFormState(initialState)
// Reset fields to the original existing data
setFormState(initial)
return
// Clear cache
!isEditing && clearCache()
},
[clearCache, isEditing, mod]
)
const handlePublish = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
submit(JSON.stringify(formState), {
method: isEditing ? 'put' : 'post',
encType: 'application/json'
})
},
[formState, isEditing, submit]
)
const extraBoxRef = useRef<HTMLDivElement>(null)
const handleExtraBoxButtonClick = () => {
if (extraBoxRef.current) {
if (extraBoxRef.current.style.display === '') {
extraBoxRef.current.style.display = 'none'
} else {
extraBoxRef.current.style.display = ''
}
}
// New - set form state to the initial (clear form state)
setFormState(initializeFormState())
}
const handlePublish = () => {
submit(JSON.stringify(formState), {
method: mod ? 'put' : 'post',
encType: 'application/json'
})
}
return (
<form
className='IBMSMSMBS_Write'
onSubmit={(e) => {
e.preventDefault()
handlePublish()
}}
>
<form className='IBMSMSMBS_Write' onSubmit={handlePublish}>
<GameDropdown
options={gameOptions}
selected={formState?.game}
@ -208,11 +287,15 @@ export const ModForm = () => {
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={markdown}
markdown={body}
placeholder="Here's what this mod is all about"
onChange={(md) => {
handleInputChange('body', md)
}}
onError={(payload) => {
toast.error('Markdown error. Fix manually in the source mode.')
log(true, LogType.Error, payload.error)
}}
/>
</div>
{typeof formErrors?.body !== 'undefined' && (
@ -226,16 +309,15 @@ export const ModForm = () => {
/>
</div>
<InputField
<InputFieldWithImageUpload
label='Featured Image URL'
description='We recommend to upload images to https://nostr.build/'
type='text'
description={`We recommend to upload images to ${MEDIA_OPTIONS[0].host}`}
inputMode='url'
placeholder='Image URL'
name='featuredImageUrl'
value={formState.featuredImageUrl}
error={formErrors?.featuredImageUrl}
onChange={handleInputChange}
onInputChange={handleInputChange}
/>
<InputField
label='Summary'
@ -299,8 +381,24 @@ export const ModForm = () => {
</button>
</div>
<p className='labelDescriptionMain'>
We recommend to upload images to https://nostr.build/
We recommend to upload images to {MEDIA_OPTIONS[0].host}
</p>
<ImageUpload
multiple={true}
onChange={(values) => {
setFormState((prevState) => ({
...prevState,
screenshotsUrls: Array.from(
new Set([
...prevState.screenshotsUrls.filter((url) => url),
...values
])
)
}))
}}
/>
{formState.screenshotsUrls.map((url, index) => (
<Fragment key={`screenShot-${index}`}>
<ScreenshotUrlFields
@ -357,20 +455,37 @@ export const ModForm = () => {
</div>
<p className='labelDescriptionMain'>
You can upload your game mod to Github, as an example, and keep
updating it there (another option is catbox.moe). Also, it's advisable
that you hash your package as well with your nostr public key.
updating it there (another option is{' '}
<a
href='https://catbox.moe/'
target='_blank'
rel='noopener noreferrer'
>
catbox.moe
</a>
). Also, it's advisable that you hash your package as well with your
nostr public key. Malware scan service suggestion:{' '}
<a
href='https://virustotal.com'
target='_blank'
rel='noopener noreferrer'
>
https://virustotal.com
</a>
</p>
{formState.downloadUrls.map((download, index) => (
<Fragment key={`download-${index}`}>
<DownloadUrlFields
index={index}
title={download.title}
url={download.url}
hash={download.hash}
signatureKey={download.signatureKey}
malwareScanLink={download.malwareScanLink}
modVersion={download.modVersion}
customNote={download.customNote}
mediaUrl={download.mediaUrl}
onUrlChange={handleDownloadUrlChange}
onRemove={removeDownloadUrl}
/>
@ -385,6 +500,131 @@ export const ModForm = () => {
<InputError message={formErrors?.downloadUrls[0]} />
)}
</div>
<div className='IBMSMSMBSSExtra'>
<button
className='btn btnMain IBMSMSMBSSExtraBtn'
type='button'
onClick={handleExtraBoxButtonClick}
>
Permissions &amp; Details
</button>
<div
className='IBMSMSMBSSExtraBox'
ref={extraBoxRef}
style={{
display: 'none'
}}
>
<p
className='labelDescriptionMain'
style={{ marginBottom: `10px`, textAlign: `center` }}
>
What permissions users have with your published mod/post
</p>
<div className='IBMSMSMBSSExtraBoxElementWrapper'>
{Object.keys(MODPERMISSIONS_CONF).map((k) => {
const permKey = k as keyof ModPermissions
const confKey = k as keyof typeof MODPERMISSIONS_CONF
const modPermission = MODPERMISSIONS_CONF[confKey]
const value = formState[permKey]
return (
<div className='IBMSMSMBSSExtraBoxElement' key={k}>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>{modPermission.header}</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<label
htmlFor={`${permKey}_true`}
className='IBMSMSMBSSExtraBoxElementColChoice'
>
<p>
{MODPERMISSIONS_DESC[`${permKey}_true`]}
<br />
</p>
<input
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
type='radio'
name={permKey}
id={`${permKey}_true`}
value={'true'}
checked={
typeof value !== 'undefined'
? value === true
: modPermission.default === true
}
onChange={(e) =>
handleRadioChange(
permKey,
e.currentTarget.value === 'true'
)
}
/>
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
</label>
<label
htmlFor={`${permKey}_false`}
className='IBMSMSMBSSExtraBoxElementColChoice'
>
<p>
{MODPERMISSIONS_DESC[`${permKey}_false`]}
<br />
</p>
<input
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
type='radio'
id={`${permKey}_false`}
value={'false'}
name={permKey}
checked={
typeof value !== 'undefined'
? value === false
: modPermission.default === false
}
onChange={(e) =>
handleRadioChange(
permKey,
e.currentTarget.value === 'true'
)
}
/>
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
</label>
</div>
</div>
)
})}
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Publisher Notes</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<textarea
className='inputMain'
value={formState.publisherNotes || ''}
onChange={(e) =>
handleInputChange('publisherNotes', e.currentTarget.value)
}
/>
</div>
</div>
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Extra Credits</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<textarea
className='inputMain'
value={formState.extraCredits || ''}
onChange={(e) =>
handleInputChange('extraCredits', e.currentTarget.value)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
@ -394,7 +634,7 @@ export const ModForm = () => {
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
{mod ? 'Reset' : 'Clear fields'}
{isEditing ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
@ -406,13 +646,21 @@ export const ModForm = () => {
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
</button>
</div>
{showTryAgainPopup && (
<AlertPopup
handleConfirm={handleTryAgainConfirm}
handleClose={() => setShowTryAgainPopup(false)}
header={'Publish'}
label={`Submission timed out. Do you want to try again?`}
/>
)}
{showConfirmPopup && (
<AlertPopup
handleConfirm={handleResetConfirm}
handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
mod
isEditing
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
@ -424,11 +672,13 @@ export const ModForm = () => {
type DownloadUrlFieldsProps = {
index: number
url: string
title?: string
hash: string
signatureKey: string
malwareScanLink: string
modVersion: string
customNote: string
mediaUrl?: string
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
onRemove: (index: number) => void
}
@ -437,11 +687,13 @@ const DownloadUrlFields = React.memo(
({
index,
url,
title,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote,
mediaUrl,
onUrlChange,
onRemove
}: DownloadUrlFieldsProps) => {
@ -479,6 +731,28 @@ const DownloadUrlFields = React.memo(
</svg>
</button>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
name='title'
placeholder='Download Title'
value={title || ''}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
@ -589,6 +863,43 @@ const DownloadUrlFields = React.memo(
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
<ImageUpload
onChange={(values) => {
onUrlChange(index, 'mediaUrl', values[0])
}}
/>
<input
type='text'
className='inputMain'
placeholder='Media URL'
name='mediaUrl'
value={mediaUrl || ''}
onChange={handleChange}
/>
</div>
<div className='inputWrapperMainBox'></div>
</div>
</div>
)
}
@ -613,7 +924,7 @@ const ScreenshotUrlFields = React.memo(
type='text'
className='inputMain'
inputMode='url'
placeholder='We recommend to upload images to https://nostr.build/'
placeholder='Image URL'
value={url}
onChange={handleChange}
/>

View File

@ -24,7 +24,10 @@ export const NsfwAlertPopup = ({
<AlertPopup
header='Confirm'
label='Are you above 18 years of age?'
handleClose={handleClose}
handleClose={() => {
handleConfirm(false)
handleClose()
}}
handleConfirm={(confirm: boolean) => {
setConfirmNsfw(confirm)
handleConfirm(confirm)

View File

@ -0,0 +1,22 @@
interface PostWarningsProps {
type: 'user' | 'admin'
}
export const PostWarnings = ({ type }: PostWarningsProps) => (
<div className='IBMSMSMBSSWarning'>
<p>
{type === 'admin' ? (
<>
Warning: This post has been blocked/hidden by the site for one of the
following reasons:
<br />
Malware, Not a Mod, Illegal, Spam, Verified Report of Unauthorized
Repost.
<br />
</>
) : (
<>Notice: You have blocked this post</>
)}
</p>
</div>
)

View File

@ -155,7 +155,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
{typeof nip05 === 'string' && (
{typeof nip05 === 'string' && nip05 !== '' && (
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
)}
</div>
@ -191,7 +191,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
{typeof lud16 !== 'undefined' && (
{typeof lud16 !== 'undefined' && lud16 !== '' && (
<ZapButtonWithPopUp pubkey={pubkey} />
)}
</div>
@ -383,7 +383,12 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
if (userState.auth && userState.user?.pubkey) {
return userState.user.pubkey as string
} else {
return (await window.nostr?.getPublicKey()) as string
try {
return (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
return null
}
}
}

View File

@ -2,6 +2,7 @@ import { getRelayListForUser } from '@nostr-dev-kit/ndk'
import { QRCodeSVG } from 'qrcode.react'
import React, {
Dispatch,
PropsWithChildren,
ReactNode,
SetStateAction,
useCallback,
@ -19,6 +20,9 @@ import {
formatNumber,
getTagValue,
getZapAmount,
log,
LogType,
timeout,
unformatNumber
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
@ -124,6 +128,7 @@ type ZapQRProps = {
handleQRExpiry: () => void
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
profileImage?: string
}
export const ZapQR = React.memo(
@ -132,8 +137,10 @@ export const ZapQR = React.memo(
handleClose,
handleQRExpiry,
setTotalZapAmount,
setHasZapped
}: ZapQRProps) => {
setHasZapped,
profileImage,
children
}: PropsWithChildren<ZapQRProps>) => {
const { ndk } = useNDKContext()
useDidMount(() => {
@ -173,7 +180,10 @@ export const ZapQR = React.memo(
}
return (
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
<div
className='inputLabelWrapperMain inputLabelWrapperMainQR'
style={{ alignItems: 'center' }}
>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
@ -181,6 +191,21 @@ export const ZapQR = React.memo(
height={235}
width={235}
/>
{profileImage && (
<div style={{ marginTop: '-20px' }}>
<img
src={profileImage}
alt='Profile Avatar'
style={{
width: '100%',
maxWidth: '50px',
borderRadius: '8px',
border: 'solid 2px #494949',
boxShadow: '0 0 4px 0 rgb(0, 0, 0, 0.1)'
}}
/>
</div>
)}
<label
className='popUpMainCardBottomLnurl'
onClick={() => {
@ -192,6 +217,7 @@ export const ZapQR = React.memo(
{paymentRequest.pr}
</label>
<Timer onTimerExpired={handleQRExpiry} />
{children}
</div>
)
}
@ -258,7 +284,7 @@ export const ZapPopUp = ({
const [amount, setAmount] = useState<number>(0)
const [message, setMessage] = useState('')
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
const [receiverMetadata, setRecieverMetadata] = useState<UserProfile>()
const userState = useAppSelector((state) => state.user)
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -268,7 +294,7 @@ export const ZapPopUp = ({
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string
let userHexKey: string | undefined
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -276,7 +302,11 @@ export const ZapPopUp = ({
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -285,7 +315,7 @@ export const ZapPopUp = ({
return null
}
setLoadingSpinnerDesc('finding receiver metadata')
setLoadingSpinnerDesc('Finding receiver metadata')
const receiverMetadata = await findMetadata(receiver)
@ -297,12 +327,17 @@ export const ZapPopUp = ({
if (!receiverMetadata?.pubkey) {
setIsLoading(false)
toast.error('pubkey is missing in receiver metadata!')
toast.error('Pubkey is missing in receiver metadata!')
return null
}
setRecieverMetadata(receiverMetadata)
// Find the receiver's read relays.
const receiverRelays = await getRelayListForUser(receiver, ndk)
const receiverRelays = await Promise.race([
getRelayListForUser(receiver, ndk),
timeout(2000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
@ -468,6 +503,7 @@ export const ZapPopUp = ({
handleQRExpiry={handleQRExpiry}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
profileImage={receiverMetadata?.image}
/>
)}
{lastNode}
@ -548,7 +584,7 @@ export const ZapSplit = ({
const generatePaymentInvoices = async () => {
if (!amount) return null
let userHexKey: string
let userHexKey: string | undefined
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -556,7 +592,11 @@ export const ZapSplit = ({
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -614,7 +654,11 @@ export const ZapSplit = ({
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
// Find the receiver's read relays.
const adminRelays = await getRelayListForUser(admin.pubkey as string, ndk)
// TODO: NDK should have native timeout in a future release
const adminRelays = await Promise.race([
getRelayListForUser(admin.pubkey as string, ndk),
timeout(2000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
@ -715,6 +759,8 @@ export const ZapSplit = ({
toast.warn('Webln is not present. Use QR code to send zap.')
setInvoices(paymentInvoices)
}
setIsLoading(false)
}
const removeInvoice = (key: string) => {
@ -729,6 +775,56 @@ export const ZapSplit = ({
if (!invoices) return null
const authorInvoice = invoices.get('author')
const feedback = (isFirst: boolean) => (
<div
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
flexWrap: 'wrap',
gridGap: '10px'
}}
>
<div
className='btn btnMain'
style={{
flexGrow: 1,
cursor: 'default',
background: isFirst ? undefined : 'unset'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
1st Invoice
</div>
<div
className='btn btnMain'
style={{
flexGrow: 1,
cursor: 'default',
background: isFirst ? 'unset' : undefined
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
2nd Invoice
</div>
</div>
)
if (authorInvoice) {
return (
<ZapQR
@ -738,7 +834,10 @@ export const ZapSplit = ({
handleQRExpiry={() => removeInvoice('author')}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
/>
profileImage={author?.image}
>
{feedback(true)}
</ZapQR>
)
}
@ -753,7 +852,10 @@ export const ZapSplit = ({
handleClose()
}}
handleQRExpiry={() => removeInvoice('admin')}
/>
profileImage={admin?.image}
>
{feedback(false)}
</ZapQR>
)
}

View File

@ -0,0 +1,157 @@
import { NDKKind } from '@nostr-dev-kit/ndk'
import { formatDate } from 'date-fns'
import { useDidMount, useNDKContext } from 'hooks'
import { useState } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom'
import { getModPageRoute, getBlogPageRoute, getProfilePageRoute } from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { nip19 } from 'nostr-tools'
import { CommentContent } from './CommentContent'
interface CommentProps {
comment: CommentEvent
}
export const Comment = ({ comment }: CommentProps) => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: undefined
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
comment.event.author.fetchProfile().then((res) => setProfile(res))
ndk
.fetchEvents({
kinds: [NDKKind.Text, NDKKind.GenericReply],
'#e': [comment.event.id]
})
.then((ndkEventsSet) => {
setCommentEvents(
Array.from(ndkEventsSet).map((ndkEvent) => ({
event: ndkEvent
}))
)
})
})
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: comment.event.pubkey
})
)
return (
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(comment.event.pubkey)}
</Link>
</div>
{comment.event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(comment.event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(comment.event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
{comment.status && (
<p className='IBMSMSMBSSCL_CBTextStatus'>
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
{comment.status}
</p>
)}
<CommentContent content={comment.event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...comment.event.rawEvent()} />
{/* <div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
style={{ cursor: 'not-allowed' }}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div> */}
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
<Zap {...comment.event.rawEvent()} />
)}
{comment.event.kind === NDKKind.GenericReply && (
<>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
to={baseUrl + comment.event.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</Link>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
to={baseUrl + comment.event.encode()}
>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</Link>
</>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { useTextLimit } from 'hooks/useTextLimit'
interface CommentContentProps {
content: string
}
export const CommentContent = ({ content }: CommentContentProps) => {
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
return (
<>
<p className='IBMSMSMBSSCL_CBText'>{text}</p>
{isTextOverflowing && (
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
<p>{isExpanded ? 'Hide' : 'View'} full post</p>
</div>
)}
</>
)
}

View File

@ -0,0 +1,41 @@
import { useState } from 'react'
type CommentFormProps = {
handleSubmit: (content: string) => Promise<boolean>
}
export const CommentForm = ({ handleSubmit }: CommentFormProps) => {
const [isSubmitting, setIsSubmitting] = useState(false)
const [commentText, setCommentText] = useState('')
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(commentText)
if (submitted) setCommentText('')
setIsSubmitting(false)
}
return (
<div className='IBMSMSMBSSCommentsCreation'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
className='IBMSMSMBSSCC_Top_Box'
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
/>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
<button
className='btnMain'
onClick={handleComment}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Comment'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,335 @@
import { formatDate } from 'date-fns'
import { useBodyScrollDisable, useNDKContext, useReplies } from 'hooks'
import { nip19 } from 'nostr-tools'
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'
import {
Link,
useLoaderData,
useLocation,
useNavigate,
useParams
} from 'react-router-dom'
import { getBlogPageRoute, getModPageRoute, getProfilePageRoute } from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { CommentsLoaderResult } from 'types/comments'
import { adjustTextareaHeight, handleCommentSubmit, hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { NDKKind } from '@nostr-dev-kit/ndk'
import { Comment } from './Comment'
import { useComments } from 'hooks/useComments'
import { CommentContent } from './CommentContent'
import { Dots } from 'components/Spinner'
export const CommentsPopup = () => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
useBodyScrollDisable(true)
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: undefined
const { event } = useLoaderData() as CommentsLoaderResult
const {
size,
parent: replyEvent,
isComplete,
root: rootEvent
} = useReplies(event.tagValue('e'))
const isRoot = event.tagValue('a') === event.tagValue('A')
const [profile, setProfile] = useState<UserProfile>()
const { commentEvents, setCommentEvents } = useComments(
event.author.pubkey,
undefined,
event.id
)
useEffect(() => {
event.author.fetchProfile().then((res) => setProfile(res))
}, [event.author])
const profileRoute = useMemo(
() =>
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: event.pubkey
})
),
[event.pubkey]
)
const navigate = useNavigate()
const [isSubmitting, setIsSubmitting] = useState(false)
const [replyText, setReplyText] = useState('')
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.currentTarget.value
setReplyText(value)
adjustTextareaHeight(e.currentTarget)
}, [])
const [visible, setVisible] = useState<CommentEvent[]>([])
const discoveredCount = commentEvents.length - visible.length
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Initial loading to indicate comments fetching (stop after 5 seconds)
const t = window.setTimeout(() => setIsLoading(false), 5000)
return () => {
window.clearTimeout(t)
}
}, [])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(replyText)
if (submitted) setReplyText('')
setIsSubmitting(false)
}
return (
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Comment replies</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => navigate('..')}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='popUpMainCardBottom'>
<div className='pUMCB_PrimeComment'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTopOther'>
<div className='IBMSMSMBSSCL_CTO'>
{replyEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='IBMSMSMBSSCL_CTOLink'
to={baseUrl + replyEvent.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CTOLinkIcon'
>
<path d='M447.1 256C447.1 273.7 433.7 288 416 288H109.3l105.4 105.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L109.3 224H416C433.7 224 447.1 238.3 447.1 256z'></path>
</svg>
</Link>
)}
<p className='IBMSMSMBSSCL_CTOText'>
Reply Depth:&nbsp;<span>{size}</span>
{!isComplete && <Dots />}
</p>
</div>
{!isRoot && rootEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='btn btnMain IBMSMSMBSSCL_CTOBtn'
type='button'
to={baseUrl + rootEvent.encode()}
>
Main Post {!isComplete && <Dots />}
</Link>
)}
</div>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link
className='IBMSMSMBSSCL_CTD_Name'
to={profileRoute}
>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link
className='IBMSMSMBSSCL_CTD_Address'
to={profileRoute}
>
{hexToNpub(event.pubkey)}
</Link>
</div>
{event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...event.rawEvent()} />
{/* <div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
style={{ cursor: 'not-allowed' }}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div> */}
{typeof profile?.lud16 !== 'undefined' &&
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
{event.kind === NDKKind.GenericReply && (
<>
<span className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>
Replies
</p>
</span>
</>
)}
</div>
</div>
</div>
</div>
<div className='pUMCB_CommentToPrime'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
className='IBMSMSMBSSCC_Top_Box postSocialTextarea'
placeholder='Got something to say?'
value={replyText}
onChange={handleChange}
style={{ height: '0px' }}
></textarea>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
{/* <a className='IBMSMSMBSSCC_BottomButton'>Quote-Repost</a> */}
<button
onClick={handleComment}
disabled={isSubmitting}
className='IBMSMSMBSSCC_BottomButton'
>
{isSubmitting ? 'Replying...' : 'Reply'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
{commentEvents.length > 0 && (
<>
<h3 className='IBMSMSMBSSCL_CommentNoteRepliesTitle'>
Replies
<button
type='button'
className='btnMain IBMSMSMBSSCL_CommentNoteRepliesTitleBtn'
onClick={
discoveredCount ? handleDiscoveredClick : undefined
}
>
<span>
{isLoading ? (
<>
Discovering replies
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered replies</>
) : (
<>No new replies</>
)}
</span>
</button>
</h3>
<div className='pUMCB_RepliesToPrime'>
{commentEvents.map((reply) => (
<Comment key={reply.event.id} comment={reply} />
))}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import React, { Dispatch, SetStateAction } from 'react'
import { AuthorFilterEnum, SortByEnum } from 'types'
export type FilterOptions = {
sort: SortByEnum
author: AuthorFilterEnum
}
type FilterProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
export const Filter = React.memo(
({ filterOptions, setFilterOptions }: FilterProps) => {
return (
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortByEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.author}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(AuthorFilterEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
author: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
)
}
)

View File

@ -0,0 +1,68 @@
import { NostrEvent } from '@nostr-dev-kit/ndk'
import { Dots } from 'components/Spinner'
import { useReactions } from 'hooks'
export const Reactions = (props: NostrEvent) => {
const {
isDataLoaded,
likesCount,
disLikesCount,
handleReaction,
hasReactedPositively,
hasReactedNegatively
} = useReactions({
pubkey: props.pubkey,
eTag: props.id!
})
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction() : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,76 @@
import { NostrEvent } from '@nostr-dev-kit/ndk'
import { ZapPopUp } from 'components/Zap'
import {
useAppSelector,
useNDKContext,
useBodyScrollDisable,
useDidMount
} from 'hooks'
import { useState } from 'react'
import { toast } from 'react-toastify'
import { abbreviateNumber } from 'utils'
export const Zap = (props: NostrEvent) => {
const [isOpen, setIsOpen] = useState(false)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const { getTotalZapAmount } = useNDKContext()
useBodyScrollDisable(isOpen)
useDidMount(() => {
getTotalZapAmount(
props.pubkey,
props.id!,
undefined,
userState.user?.pubkey as string
)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
setHasZapped(res.hasZapped)
})
.catch((err) => {
toast.error(err.message || err)
})
})
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEBolt ${
hasZapped ? 'IBMSMSMBSSCL_CAEBoltActive' : ''
}`}
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{abbreviateNumber(totalZappedAmount)}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{isOpen && (
<ZapPopUp
title='Tip/Zap'
receiver={props.pubkey}
eventId={props.id}
handleClose={() => setIsOpen(false)}
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
/>
)}
</>
)
}

View File

@ -1,48 +1,20 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Dots, Spinner } from 'components/Spinner'
import { ZapPopUp } from 'components/Zap'
import { formatDate } from 'date-fns'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext,
useReactions
} from 'hooks'
import { Dots } from 'components/Spinner'
import { useNDKContext } from 'hooks'
import { useComments } from 'hooks/useComments'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useState
} from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getProfilePageRoute } from 'routes'
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
import { useLoaderData } from 'react-router-dom'
import {
Addressable,
AuthorFilterEnum,
BlogPageLoaderResult,
CommentEvent,
CommentEventStatus,
UserProfile
} from 'types/index.ts'
import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils'
enum SortByEnum {
Latest = 'Latest',
Oldest = 'Oldest'
}
enum AuthorFilterEnum {
All_Comments = 'All Comments',
Creator_Comments = 'Creator Comments'
}
type FilterOptions = {
sort: SortByEnum
author: AuthorFilterEnum
}
ModPageLoaderResult,
SortByEnum
} from 'types'
import { handleCommentSubmit } from 'utils'
import { Filter, FilterOptions } from './Filter'
import { CommentForm } from './CommentForm'
import { Comment } from './Comment'
type Props = {
addressable: Addressable
@ -50,11 +22,14 @@ type Props = {
}
export const Comments = ({ addressable, setCommentCount }: Props) => {
const { ndk, publish } = useNDKContext()
const { ndk } = useNDKContext()
const { commentEvents, setCommentEvents } = useComments(
addressable.author,
addressable.aTag
)
const { event } = useLoaderData() as
| ModPageLoaderResult
| BlogPageLoaderResult
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
@ -73,122 +48,16 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount])
const userState = useAppSelector((state) => state.user)
const handleSubmit = async (content: string): Promise<boolean> => {
if (content === '') return false
let pubkey: string
if (userState.auth && userState.user?.pubkey) {
pubkey = userState.user.pubkey as string
} else {
pubkey = (await window.nostr?.getPublicKey()) as string
}
if (!pubkey) {
toast.error('Could not get user pubkey')
return false
}
const unsignedEvent: UnsignedEvent = {
content: content,
pubkey: pubkey,
kind: kinds.ShortTextNote,
created_at: now(),
tags: [
['e', addressable.id],
['a', addressable.aTag],
['p', addressable.author]
]
}
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) return false
setCommentEvents((prev) => [
{
...signedEvent,
status: CommentEventStatus.Publishing
},
...prev
])
const ndkEvent = new NDKEvent(ndk, signedEvent)
publish(ndkEvent)
.then((publishedOnRelays) => {
if (publishedOnRelays.length === 0) {
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
return {
...event,
status: CommentEventStatus.Failed
}
}
return event
})
)
} else {
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
return {
...event,
status: CommentEventStatus.Published
}
}
return event
})
)
}
// when an event is successfully published remove the status from it after 15 seconds
setTimeout(() => {
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
delete event.status
}
return event
})
)
}, 15000)
})
.catch((err) => {
console.error('An error occurred in publishing comment', err)
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
return {
...event,
status: CommentEventStatus.Failed
}
}
return event
})
)
})
return true
}
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
@ -199,14 +68,22 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.pubkey === addressable.author
(comment) => comment.event.pubkey === addressable.author
)
}
if (filterOptions.sort === SortByEnum.Latest) {
filteredComments.sort((a, b) => b.created_at - a.created_at)
filteredComments.sort((a, b) =>
a.event.created_at && b.event.created_at
? b.event.created_at - a.event.created_at
: 0
)
} else if (filterOptions.sort === SortByEnum.Oldest) {
filteredComments.sort((a, b) => a.created_at - b.created_at)
filteredComments.sort((a, b) =>
a.event.created_at && b.event.created_at
? a.event.created_at - b.event.created_at
: 0
)
}
return filteredComments
@ -220,380 +97,35 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
{/* Hide comment form if aTag is missing */}
{!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />}
<div>
{isLoading ? (
<Spinner />
) : (
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>Load {discoveredCount} discovered comments</span>
</button>
)}
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>
{isLoading ? (
<>
Discovering comments
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered comments</>
) : (
<>No new comments</>
)}
</span>
</button>
</div>
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSMSMBSSCommentsList'>
{comments.map((event) => (
<Comment key={event.id} {...event} />
{comments.map((comment) => (
<Comment key={comment.event.id} comment={comment} />
))}
</div>
</div>
</div>
)
}
type CommentFormProps = {
handleSubmit: (content: string) => Promise<boolean>
}
const CommentForm = ({ handleSubmit }: CommentFormProps) => {
const [isSubmitting, setIsSubmitting] = useState(false)
const [commentText, setCommentText] = useState('')
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(commentText)
if (submitted) setCommentText('')
setIsSubmitting(false)
}
return (
<div className='IBMSMSMBSSCommentsCreation'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
className='IBMSMSMBSSCC_Top_Box'
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
/>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
<button
className='btnMain'
onClick={handleComment}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Comment'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
)
}
type FilterProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
const Filter = React.memo(
({ filterOptions, setFilterOptions }: FilterProps) => {
return (
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortByEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.author}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(AuthorFilterEnum).map((item) => (
<div
key={`sortBy-${item}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
author: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
)
}
)
const Comment = (props: CommentEvent) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
findMetadata(props.pubkey).then((res) => {
setProfile(res)
})
})
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: props.pubkey
})
)
return (
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(props.pubkey)}
</Link>
</div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(props.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(props.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
{props.status && (
<p className='IBMSMSMBSSCL_CBTextStatus'>
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
{props.status}
</p>
)}
<p className='IBMSMSMBSSCL_CBText'>{props.content}</p>
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...props} />
<div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
style={{ cursor: 'not-allowed' }}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<Zap {...props} />
<div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
style={{ cursor: 'not-allowed' }}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</div>
<div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
style={{ cursor: 'not-allowed' }}
>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</div>
</div>
</div>
</div>
)
}
const Reactions = (props: Event) => {
const {
isDataLoaded,
likesCount,
disLikesCount,
handleReaction,
hasReactedPositively,
hasReactedNegatively
} = useReactions({
pubkey: props.pubkey,
eTag: props.id
})
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction() : undefined}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
</>
)
}
const Zap = (props: Event) => {
const [isOpen, setIsOpen] = useState(false)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [hasZapped, setHasZapped] = useState(false)
const userState = useAppSelector((state) => state.user)
const { getTotalZapAmount } = useNDKContext()
useBodyScrollDisable(isOpen)
useDidMount(() => {
getTotalZapAmount(
props.pubkey,
props.id,
undefined,
userState.user?.pubkey as string
)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
setHasZapped(res.hasZapped)
})
.catch((err) => {
toast.error(err.message || err)
})
})
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEBolt ${
hasZapped ? 'IBMSMSMBSSCL_CAEBoltActive' : ''
}`}
onClick={() => setIsOpen(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-64 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{abbreviateNumber(totalZappedAmount)}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{isOpen && (
<ZapPopUp
title='Tip/Zap'
receiver={props.pubkey}
eventId={props.id}
handleClose={() => setIsOpen(false)}
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
/>
)}
</>
)
}

View File

@ -123,3 +123,4 @@ export const FALLBACK_PROFILE_IMAGE =
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
export const PROFILE_BLOG_FILTER_LIMIT = 20
export const MAX_VISIBLE_TEXT_PER_COMMENT = 500

View File

@ -252,7 +252,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
})
.catch((err) => {
log(
true,
false, // Too many failed requests, turned off for clarity
LogType.Error,
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
@ -369,16 +369,14 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
const publish = async (event: NDKEvent): Promise<string[]> => {
if (!event.sig) throw new Error('Before publishing first sign the event!')
return event
.publish(undefined, 10000)
.then((res) => {
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
})
.catch((err) => {
console.error(`An error occurred in publishing event`, err)
return []
})
try {
const res = await event.publish(undefined, 10000)
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
} catch (err) {
console.error(`An error occurred in publishing event`, err)
return []
}
}
/**

View File

@ -0,0 +1,74 @@
import { DropzoneOptions } from 'react-dropzone'
import { NostrCheckServer } from './nostrcheck-server'
import { BaseError } from 'types'
export interface MediaOperations {
post: (file: File) => Promise<string>
}
export type MediaStrategy = Omit<MediaOperations, 'auth'>
export interface MediaOption {
name: string
host: string
type: 'nostrcheck-server' | 'route96'
}
// nostr.build based dropzone options
export const MEDIA_DROPZONE_OPTIONS: DropzoneOptions = {
maxSize: 7000000,
accept: {
'image/*': ['.jpeg', '.png', '.jpg', '.gif', '.webp']
}
}
export const MEDIA_OPTIONS: MediaOption[] = [
// {
// name: 'nostr.build',
// host: 'https://nostr.build/',
// type: 'nostrcheck-server'
// },
{
name: 'nostrcheck.me',
host: 'https://nostrcheck.me/',
type: 'nostrcheck-server'
},
{
name: 'nostpic.com',
host: 'https://nostpic.com/',
type: 'nostrcheck-server'
},
{
name: 'files.sovbit.host',
host: 'https://files.sovbit.host/',
type: 'nostrcheck-server'
}
// {
// name: 'void.cat',
// host: 'https://void.cat/',
// type: 'route96'
// }
]
enum ImageErrorType {
'TYPE_MISSING' = 'Media Option must include a type.'
}
export class ImageController implements MediaStrategy {
post: (file: File) => Promise<string>
constructor(mediaOption: MediaOption) {
let strategy: MediaStrategy
switch (mediaOption.type) {
case 'nostrcheck-server':
strategy = new NostrCheckServer(mediaOption.host)
this.post = strategy.post
break
case 'route96':
throw new Error('Not implemented.')
default:
throw new BaseError(ImageErrorType.TYPE_MISSING)
}
}
}

View File

@ -0,0 +1,166 @@
import axios, { isAxiosError } from 'axios'
import { NostrEvent, NDKKind } from '@nostr-dev-kit/ndk'
import { type MediaOperations } from '.'
import { store } from 'store'
import { log, LogType, now } from 'utils'
import { BaseError, handleError } from 'types'
// https://github.com/quentintaranpino/nostrcheck-server/blob/main/DOCS.md#media-post
// Response object (other fields omitted for brevity)
// {
// "status": "success",
// "nip94_event": {
// "tags": [
// [
// "url",
// "https://nostrcheck.me/media/62c76eb094369d938f5895442eef7f53ebbf019f69707d64e77d4d182b609309/c35277dbcedebb0e3b80361762c8baadb66dcdfb6396949e50630159a472c3b2.webp"
// ],
// ],
// }
// }
interface Response {
status: 'success' | string
nip94_event?: {
tags?: string[][]
}
}
enum HandledErrorType {
'PUBKEY' = 'Failed to get public key.',
'SIGN' = 'Failed to sign the event.',
'AXIOS_REQ' = 'Image upload failed. Try another host from the dropdown.',
'AXIOS_RES' = 'Image upload failed. Reason: ',
'AXIOS_ERR' = 'Image upload failed.',
'NOSTR_CHECK_NO_SUCCESS' = 'Image upload was unsuccesfull.',
'NOSTR_CHECK_BAD_EVENT' = 'Image upload failed. Please try again.'
}
export class NostrCheckServer implements MediaOperations {
#media = 'api/v2/media'
#url: string
constructor(url: string) {
this.#url = url[url.length - 1] === '/' ? url : `${url}/`
}
post = async (file: File) => {
const url = `${this.#url}${this.#media}`
const auth = await this.auth()
try {
const response = await axios.postForm<Response>(
url,
{
uploadType: 'media',
file: file
},
{
headers: {
Authorization: 'Nostr ' + auth,
'Content-Type': 'multipart/form-data'
},
responseType: 'json'
}
)
if (response.data.status !== 'success') {
throw new BaseError(HandledErrorType.NOSTR_CHECK_NO_SUCCESS, {
context: { ...response.data }
})
}
if (
response.data &&
response.data.nip94_event &&
response.data.nip94_event.tags &&
response.data.nip94_event.tags.length
) {
// Return first 'url' tag we find on the returned nip94 event
const imageUrl = response.data.nip94_event.tags.find(
(item) => item[0] === 'url'
)
if (imageUrl) return imageUrl[1]
}
throw new BaseError(HandledErrorType.NOSTR_CHECK_BAD_EVENT, {
context: { ...response.data }
})
} catch (error) {
// Handle axios errors
if (isAxiosError(error)) {
if (error.request) {
// The request was made but no response was received
throw new BaseError(HandledErrorType.AXIOS_REQ, {
cause: error
})
} else if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// nostrcheck-server can return different results, including message or description
const data = error.response.data
let message = error.message
if (data) {
message = data?.message || data?.description || error.message
}
throw new BaseError(HandledErrorType.AXIOS_RES + message, {
cause: error
})
} else {
// Something happened in setting up the request that triggered an Error
throw new BaseError(HandledErrorType.AXIOS_ERR, {
cause: error
})
}
} else if (error instanceof BaseError) {
throw error
} else {
throw handleError(error)
}
}
}
auth = async () => {
try {
const url = `${this.#url}${this.#media}`
let hexPubkey: string | undefined
const userState = store.getState().user
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {
throw new BaseError(HandledErrorType.PUBKEY)
}
const unsignedEvent: NostrEvent = {
content: '',
created_at: now(),
kind: NDKKind.HttpAuth,
pubkey: hexPubkey,
tags: [
['u', url],
['method', 'POST']
]
}
const signedEvent = await window.nostr?.signEvent(unsignedEvent)
return btoa(JSON.stringify(signedEvent))
} catch (error) {
if (error instanceof BaseError) {
throw error
}
throw new BaseError(HandledErrorType.SIGN, {
cause: handleError(error)
})
}
}
}

View File

@ -0,0 +1,7 @@
import { MediaOperations } from '.'
export class route96 implements MediaOperations {
post = () => {
throw new Error('route96 post Not implemented.')
}
}

View File

@ -1 +1,2 @@
export * from './zap'
export * from './image'

View File

@ -4,8 +4,11 @@ export * from './useFilteredMods'
export * from './useGames'
export * from './useMuteLists'
export * from './useNSFWList'
export * from './useRepostList'
export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'
export * from './useSessionStorage'
export * from './useLocalCache'
export * from './useReplies'

View File

@ -13,14 +13,15 @@ import { useNDKContext } from './useNDKContext'
export const useComments = (
author: string | undefined,
aTag: string | undefined
aTag: string | undefined,
eTag?: string | undefined
) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => {
if (!(author && aTag)) {
// Author and aTag are required
if (!(author && (aTag || eTag))) {
// Author and aTag/eTag are required
return
}
@ -39,7 +40,7 @@ export const useComments = (
})
.catch((err) => {
log(
true,
false, // Too many failed requests, turned off for clarity
LogType.Error,
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
err
@ -48,8 +49,17 @@ export const useComments = (
})
const filter: NDKFilter = {
kinds: [NDKKind.Text],
'#a': [aTag]
kinds: [NDKKind.Text, NDKKind.GenericReply],
...(aTag
? {
'#a': [aTag]
}
: {}),
...(eTag
? {
'#e': [eTag]
}
: {})
}
const relayUrls = new Set<string>()
@ -73,21 +83,11 @@ export const useComments = (
subscription.on('event', (ndkEvent) => {
setCommentEvents((prev) => {
if (prev.find((e) => e.id === ndkEvent.id)) {
if (prev.find((e) => e.event.id === ndkEvent.id)) {
return [...prev]
}
const commentEvent: CommentEvent = {
kind: NDKKind.Text,
tags: ndkEvent.tags,
content: ndkEvent.content,
created_at: ndkEvent.created_at!,
pubkey: ndkEvent.pubkey,
id: ndkEvent.id,
sig: ndkEvent.sig!
}
return [commentEvent, ...prev]
return [{ event: ndkEvent }, ...prev]
})
})
@ -102,7 +102,7 @@ export const useComments = (
subscription.stop()
}
}
}, [aTag, author, ndk])
}, [aTag, author, eTag, ndk])
return {
commentEvents,

View File

@ -112,7 +112,7 @@ export const useFilteredMods = (
case WOTFilterOptions.Site_And_Mine:
return mods.filter(
(mod) =>
isInWoT(siteWot, siteWotLevel, mod.author) ||
isInWoT(siteWot, siteWotLevel, mod.author) &&
isInWoT(userWot, userWotLevel, mod.author)
)
}
@ -128,10 +128,19 @@ export const useFilteredMods = (
npubToHex(userState.user.npub as string) === author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
const isOnlyBlocked =
filterOptions.moderated === ModeratedFilter.Only_Blocked
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
if (isOnlyBlocked && isAdmin) {
filtered = filtered.filter(
(mod) =>
muteLists.admin.authors.includes(mod.author) ||
muteLists.admin.replaceableEvents.includes(mod.aTag)
)
} else if (isUnmoderatedFully && (isAdmin || isOwner)) {
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
} else {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react'
import { setLocalStorageItem, removeLocalStorageItem } from 'utils'
export function useLocalCache<T>(
key: string
): [
T | undefined,
React.Dispatch<React.SetStateAction<T | undefined>>,
() => void
] {
const [cache, setCache] = useState<T | undefined>(() => {
const storedValue = window.localStorage.getItem(key)
if (storedValue === null) return undefined
// Parse the value
const parsedStoredValue = JSON.parse(storedValue)
return parsedStoredValue
})
useEffect(() => {
try {
if (cache) {
setLocalStorageItem(key, JSON.stringify(cache))
} else {
removeLocalStorageItem(key)
}
} catch (e) {
console.warn(e)
}
}, [cache, key])
const clearCache = useCallback(() => {
setCache(undefined)
}, [])
return [cache, setCache, clearCache]
}

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react'
import {
getLocalStorageItem,
mergeWithInitialValue,
removeLocalStorageItem,
setLocalStorageItem
} from 'utils'
@ -10,17 +11,6 @@ const useLocalStorageSubscribe = (callback: () => void) => {
return () => window.removeEventListener('storage', callback)
}
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
if (
!Array.isArray(storedValue) &&
typeof storedValue === 'object' &&
storedValue !== null
) {
return { ...initialValue, ...storedValue }
}
return storedValue
}
export function useLocalStorage<T>(
key: string,
initialValue: T

View File

@ -70,12 +70,16 @@ export const useReactions = (params: UseReactionsParams) => {
}, [reactionEvents, userState])
const getPubkey = async () => {
let hexPubkey: string
let hexPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {

53
src/hooks/useReplies.tsx Normal file
View File

@ -0,0 +1,53 @@
import {
NDKEvent,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
export const useReplies = (eTag: string | undefined) => {
const { ndk } = useNDKContext()
const [replies, setReplies] = useState<NDKEvent[]>([])
const [isComplete, setIsComplete] = useState(false)
useDidMount(async () => {
if (!eTag) {
setIsComplete(true)
return
}
let eDepth: string | undefined = eTag
while (eDepth) {
const previousReply = await ndk.fetchEvent(
{
kinds: [NDKKind.Text, NDKKind.GenericReply],
ids: [eDepth]
},
{
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
}
)
if (previousReply) {
setReplies((p) => {
if (p.findIndex((p) => p.id === previousReply.id) === -1) {
p.push(previousReply)
}
return p
})
eDepth = previousReply.tagValue('e')
} else {
eDepth = undefined
}
}
setIsComplete(true)
})
return {
size: replies.length,
isComplete,
parent: replies.length > 0 ? replies[0] : undefined,
root: isComplete ? replies[replies.length - 1] : undefined
}
}

View File

@ -0,0 +1,19 @@
import { useState } from 'react'
import { useDidMount } from './useDidMount'
import { useNDKContext } from './useNDKContext'
import { CurationSetIdentifiers, getReportingSet } from 'utils'
export const useRepostList = () => {
const ndkContext = useNDKContext()
const [repostList, setRepostList] = useState<string[]>([])
useDidMount(async () => {
const list = await getReportingSet(
CurationSetIdentifiers.Repost,
ndkContext
)
setRepostList(list)
})
return repostList
}

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react'
import {
getSessionStorageItem,
mergeWithInitialValue,
removeSessionStorageItem,
setSessionStorageItem
} from 'utils'
@ -10,17 +11,6 @@ const useSessionStorageSubscribe = (callback: () => void) => {
return () => window.removeEventListener('sessionStorage', callback)
}
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
if (
!Array.isArray(storedValue) &&
typeof storedValue === 'object' &&
storedValue !== null
) {
return { ...initialValue, ...storedValue }
}
return storedValue
}
export function useSessionStorage<T>(
key: string,
initialValue: T

View File

@ -0,0 +1,19 @@
import { MAX_VISIBLE_TEXT_PER_COMMENT } from '../constants'
import { useState } from 'react'
export const useTextLimit = (
text: string,
limit: number = MAX_VISIBLE_TEXT_PER_COMMENT
) => {
const [isExpanded, setIsExpanded] = useState(false)
const isTextOverflowing = text.length > limit
const updated =
isExpanded || !isTextOverflowing ? text : text.slice(0, limit) + '…'
return {
text: updated,
isTextOverflowing,
isExpanded,
toggle: () => setIsExpanded((prev) => !prev)
}
}

View File

@ -1,4 +1,6 @@
import { Link } from 'react-router-dom'
import styles from '../styles/footer.module.scss'
import { appRoutes, getProfilePageRoute } from 'routes'
export const Footer = () => {
return (
@ -7,6 +9,7 @@ export const Footer = () => {
<p className={styles.secMainFooterPara}>
Built with&nbsp;
<a
rel='noopener'
className={styles.secMainFooterParaLink}
href='https://github.com/nostr-protocol/nostr'
target='_blank'
@ -14,21 +17,26 @@ export const Footer = () => {
Nostr
</a>{' '}
by&nbsp;
<a
<Link
className={styles.secMainFooterParaLink}
href='https://degmods.com/profile/nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
to={getProfilePageRoute(
'nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
)}
target='_blank'
>
Freakoverse
</a>
</Link>
, with the support of{' '}
<a className={styles.secMainFooterParaLink} href='backers.html'>
<Link
className={styles.secMainFooterParaLink}
to={appRoutes.supporters}
>
Supporters
</a>
</Link>
. Check our&nbsp;
<a className={styles.secMainFooterParaLink} href='backup.html'>
<Link className={styles.secMainFooterParaLink} to={appRoutes.backup}>
Backup Plan
</a>
</Link>
.
</p>
</div>

View File

@ -3,7 +3,7 @@ import {
launch as launchNostrLoginDialog
} from 'nostr-login'
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useRevalidator } from 'react-router-dom'
import { Banner } from '../components/Banner'
import { ZapPopUp } from '../components/Zap'
import {
@ -22,12 +22,13 @@ import { npubToHex } from '../utils'
import logo from '../assets/img/DEG Mods Logo With Text.svg'
import placeholder from '../assets/img/DEG Mods Default PP.png'
import { resetUserWot } from 'store/reducers/wot'
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
export const Header = () => {
const dispatch = useAppDispatch()
const { findMetadata } = useNDKContext()
const { findMetadata, ndk } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const revalidator = useRevalidator()
// Track nostr-login extension modal open state
const [isOpen, setIsOpen] = useState(false)
const handleOpen = () => setIsOpen(true)
@ -50,6 +51,7 @@ export const Header = () => {
dispatch(setAuth(null))
dispatch(setUser(null))
dispatch(resetUserWot())
ndk.signer = undefined
} else {
dispatch(
setAuth({
@ -63,6 +65,7 @@ export const Header = () => {
pubkey: npubToHex(npub)!
})
)
ndk.signer = new NDKNip07Signer()
findMetadata(npub).then((userProfile) => {
if (userProfile) {
dispatch(
@ -75,8 +78,12 @@ export const Header = () => {
}
})
}
// React router - revalidate loader states on auth changes
revalidator.revalidate()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, findMetadata])
const handleLogin = () => {
@ -436,6 +443,21 @@ const RegisterButtonWithDialog = () => {
nos2x
</a>
</div>
<p
className='labelDescriptionMain'
style={{
padding: '10px',
borderRadius: '10px',
background: 'rgba(205,44,255,0.1)',
border: 'solid 2px rgba(255,66,235,0.3)',
margin: '10px 0 0 0',
color: '#ffffffbf'
}}
>
Warning:&nbsp;Make sure you backup your private key
somewhere safe. If you lose it or it gets leaked, we
actually can't help you.
</p>
<p
className='labelDescriptionMain'
style={{

View File

@ -44,7 +44,7 @@ export const Layout = () => {
})
}
}
}, [ndk, dispatch])
}, [dispatch, ndk])
// calculate user's wot
useEffect(() => {
@ -60,7 +60,7 @@ export const Layout = () => {
toast.error('An error occurred in calculating user web-of-trust!')
})
}
}, [ndk, userState.user, dispatch])
}, [dispatch, ndk, userState.user?.pubkey])
// get site's wot level
useEffect(() => {
@ -106,7 +106,7 @@ export const Layout = () => {
})
}
}
}, [userState.user, dispatch, fetchEventFromUserRelays])
}, [dispatch, fetchEventFromUserRelays, userState.user?.pubkey])
return (
<>

37
src/loaders/comment.ts Normal file
View File

@ -0,0 +1,37 @@
import { NDKContextType } from 'contexts/NDKContext'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { CommentsLoaderResult } from 'types/comments'
import { log, LogType } from 'utils'
export const commentsLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
const { nevent } = params
if (!nevent) {
log(true, LogType.Error, 'Required nevent.')
return redirect('..')
}
try {
const replyEvent = await ndkContext.ndk.fetchEvent(nevent)
if (!replyEvent) {
throw new Error('We are unable to find the comment on the relays')
}
const result: Partial<CommentsLoaderResult> = {
event: replyEvent
}
return result
} catch (error) {
let message = 'An error occurred in fetching comment from relays'
log(true, LogType.Error, message, error)
if (error instanceof Error) {
message = error.message
throw new Error(message)
}
}
return redirect('..')
}

View File

@ -1,4 +1,4 @@
import { Link, useRouteError } from 'react-router-dom'
import { Link, useLocation, useRouteError } from 'react-router-dom'
import { appRoutes } from 'routes'
interface NotFoundPageProps {
@ -12,6 +12,8 @@ export const NotFoundPage = ({
}: Partial<NotFoundPageProps>) => {
const error = useRouteError() as Partial<NotFoundPageProps>
const location = useLocation()
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -23,7 +25,19 @@ export const NotFoundPage = ({
<div>
<p>{error?.message || message}</p>
</div>
<div className='IBMSMAction'>
<div
className='IBMSMAction'
style={{
gap: '10px'
}}
>
<Link
to={location.pathname}
className='btn btnMain IBMSMActionBtn'
type='button'
>
Try again
</Link>
<Link
to={appRoutes.home}
className='btn btnMain IBMSMActionBtn'

View File

@ -13,14 +13,22 @@ export type FAQItem = {
const FAQ_ITEMS: FAQItem[] = [
{
question: "You don't host mod files?",
answer: `We don't handle that directly, but you, as the creator, will.`
answer: `We could, but that's not the focus. When a creator publishes a mod, they have the option to upload their mod files
to whichever server they want and simply add the link to their post. To the end user, there's no difference; they'd always see
a 'Download' button. This results in the censorship-resistant structure of a creator's mod post. However, later on, we'll be
implementing a system where creators can upload within the site (not to the site) and choose from multiple server hosts
(to upload to one or more), with us potentially offering a backup. This system would also provide censorship resistance to
the mod files themselves, as anyone would be able to host the files, back them up, and provide them for the public to download from,
decreasing the chance of the file getting taken down or lost.`
},
{
question:
'How do you assure security of game mod files that someone downloads?',
answer: `We don't assure security directly. However, we will provide a reaction
system to help users gauge the safety of download links, and mod creators
are encouraged to include scan links.`
answer: `When a mod creator attempts to publish a mod, there is a security field that they can fill out
that shows a scan report of the files, and users would be able to see that report. If a mod creator doesn't add a report,
a prominent warning would be shown to the user on a mod's post (TBA, if not already added). Later, when the new file uploading
system (Blossom) gets implemented, along with further security implementations, a scan would automatically happen (depending on
the server that it is being uploaded to), and a report would be auto-generated and shared within the mod post.`
},
{
question: "Why are you quoting 'account'?",
@ -41,8 +49,11 @@ const FAQ_ITEMS: FAQItem[] = [
{
question:
"You can't do anything about any mod or person? Nothing at all? What about the illegal stuff?",
answer: `Direct removal or banning is not possible. We can only filter or
hide content on the site, but it remains accessible on here and elsewhere.`
answer: `While we can't directly take down mod posts or ban user accounts, the best that we can do is hide
posts from initially being viewed on the site. However, they can still be accessible if a user has a direct
link, and they can also be accessible on different sites running the same protocol and similar setup to DEG Mods.
When non-mods, harmful, or illegal posts are published, such posts would be discovered and then hidden.
Afterwards, relevant authorities would handle the rest.`
},
{
question:
@ -57,13 +68,12 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'Is this an open-source project?',
answer: `Yes, DEG Mods is open-source. You can access the code repository
[here](https://github.com/your-repo).`
here (sharing soon).`
},
{
question: "Who's developing / maintaining DEG Mods?",
answer: `Considering this is an open-source project, anyone can contribute to its development and maintenance.
With that said, the initial idea-tor, designer, and frontend developer is [Freakoverse](https://degmods.com/profile/nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp), and the co-developer
is [Nostr Dev](https://nostrdev.com/).`
With that said, the project was ideated, launched, and led by Freakoverse.`
},
{
question: "Who's that character above with the orange hair?",
@ -71,9 +81,7 @@ const FAQ_ITEMS: FAQItem[] = [
},
{
question: "Who's that character above with the purple hair?",
answer: `That's Moda-chan. DEG Mods' mascot. She's a master game mod creator! (Yes, she was AI-generated,
as such her design is temporary and will be replaced with a design created by an artist (or artists)
when that time comes.)`
answer: `That's Moda-chan. DEG Mods' mascot. She's a game mod creator!`
}
]

126
src/pages/backup.tsx Normal file
View File

@ -0,0 +1,126 @@
import { capitalizeEachWord } from 'utils'
import '../styles/backup.css'
import backupPlanImg from '../assets/img/DEG Mods Backup Plan.png'
// import placeholder from '../assets/img/DEGMods Placeholder Img.png'
interface BackupItemProps {
name: string
image: string
link: string
type: 'repo' | 'alt' | 'exe'
}
const BACKUP_LIST: BackupItemProps[] = [
// {
// name: 'Github',
// type: 'repo',
// image:
// 'https://www.c-sharpcorner.com/article/create-github-repository-and-add-newexisting-project-using-github-desktop/Images/github.png',
// link: '#'
// },
// {
// name: 'Github, but nostr',
// type: 'repo',
// image: 'https://vitorpamplona.com/images/nostr.gif',
// link: '#'
// },
// {
// name: 'name',
// type: 'alt',
// image: placeholder,
// link: '#'
// },
// {
// name: '',
// type: 'exe',
// image: placeholder,
// link: '#'
// }
]
const BackupItem = ({ name, image, link, type }: BackupItemProps) => {
return (
<a
className='backupListLink'
href={link}
style={{
background: `linear-gradient(15deg, rgba(0,0,0,0.75), rgba(0,0,0,0.25)),
url("${image}") center / cover no-repeat,
linear-gradient(45deg, rgba(0,0,0,0.1), rgba(255,255,255,0.01) 50%, rgba(0,0,0,0.1))`
}}
target='_blank'
>
<div className='backupListLinkInside'>
<h3>
{type === 'exe' ? type.toUpperCase() : capitalizeEachWord(type)}:{' '}
{name}
</h3>
</div>
</a>
)
}
export const BackupPage = () => {
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup'>
<div className='IBMSecMain'>
<div className='AboutSec'>
<div className='LearnText'>
<div className='LearnTextInside'>
<h1
className='LearnTextHeading'
style={{ textAlign: 'center' }}
>
Backup Plan: Repos, Alts, EXE
</h1>
<img alt='' src={backupPlanImg} />
<p className='LearnTextPara'>
It's pretty clear that authoritarianism and censorship is on
the rise, on all fronts, and from what can be seen, any idea
that push for the opposite gets attacked. That's why DEG
Mods is running on Nostr, and that's why we're also writing
this backup plan.
<br />
</p>
<h3 className='LearnTextHeading'>Repositories</h3>
<p className='LearnTextPara'>
Wherever we can, we'll put DEG Mods' code on multiple
repositories such as Github, and (github but on nostr).
Below you can find the links where we've uploaded the site's
code to.
<br />
</p>
<h3 className='LearnTextHeading'>Alternatives</h3>
<p className='LearnTextPara'>
With the repositories for DEG Mods is up on multiple places,
we encourage people to take the code and duplicate it
elsewhere. Fork it, change the design, remove or add systems
and features, and make your own version. Below you can find
links of alts that we've found.
<br />
</p>
<h3 className='LearnTextHeading'>EXE</h3>
<p className='LearnTextPara'>
One last push we'd like to do is to create a .exe that'll
open up DEG Mods on your PC, as if you've opened the website
normally, with almost all of the functionalities you'd
expect (if not all). We want to do this so that in case
there are no alternatives, or that they're getting shut
down, then you can just rely on this instead. The link to it
will be added here the moment it becomes available.
<br />
</p>
<div className='backupList'>
{BACKUP_LIST.map((b) => (
<BackupItem {...b} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -33,11 +33,15 @@ export const blogRouteAction =
}
const userState = store.getState().user
let hexPubkey: string
let hexPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {

View File

@ -3,7 +3,10 @@ import {
useLoaderData,
Link as ReactRouterLink,
useNavigation,
useSubmit
useSubmit,
Outlet,
useParams,
useNavigate
} from 'react-router-dom'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ProfileSection } from 'components/ProfileSection'
@ -15,9 +18,12 @@ import { Interactions } from 'components/Internal/Interactions'
import { BlogCard } from 'components/BlogCard'
import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { useAppSelector, useBodyScrollDisable, useLocalStorage } from 'hooks'
import { ReportPopup } from 'components/ReportPopup'
import { Viewer } from 'components/Markdown/Viewer'
import { PostWarnings } from 'components/PostWarning'
import { appRoutes } from 'routes'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -29,12 +35,14 @@ const BLOG_REPORT_REASONS = [
]
export const BlogPage = () => {
const { blog, latest, isAddedToNSFW, isBlocked } =
const { nevent } = useParams()
const { blog, latest, isAddedToNSFW, isBlocked, postWarning } =
useLoaderData() as BlogPageLoaderResult
const userState = useAppSelector((state) => state.user)
const isAdmin =
userState.user?.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const isLoggedIn = userState.auth && userState.user?.pubkey !== 'undefined'
const navigation = useNavigation()
const [commentCount, setCommentCount] = useState(0)
@ -74,6 +82,17 @@ export const BlogPage = () => {
}
}
const navigate = useNavigate()
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(
(blog?.nsfw ?? false) && !confirmNsfw
)
const handleConfirm = (confirm: boolean) => {
if (!confirm) {
navigate(appRoutes.home)
}
}
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -83,6 +102,7 @@ export const BlogPage = () => {
<>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
{postWarning && <PostWarnings type={postWarning} />}
<div className='IBMSMSMBSSPost'>
<div
className='dropdown dropdownMain dropdownMainBlogpost'
@ -172,23 +192,25 @@ export const BlogPage = () => {
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(Date.now())}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
{isLoggedIn && (
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(Date.now())}
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
@ -307,6 +329,13 @@ export const BlogPage = () => {
</>
)}
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
<Outlet key={nevent} />
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</div>
</div>
</div>

View File

@ -13,6 +13,7 @@ import {
} from 'types'
import {
DEFAULT_FILTER_OPTIONS,
getFallbackPubkey,
getLocalStorageItem,
log,
LogType
@ -41,7 +42,9 @@ export const blogRouteLoader =
}
const userState = store.getState().user
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
const loggedInUserPubkey =
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
// Check if editing and the user is the original author
// Redirect if NOT
@ -91,6 +94,7 @@ export const blogRouteLoader =
])
const result: BlogPageLoaderResult = {
blog: undefined,
event: undefined,
latest: [],
isAddedToNSFW: false,
isBlocked: false
@ -99,6 +103,9 @@ export const blogRouteLoader =
// Check the blog event result
const fetchEventResult = settled[0]
if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) {
// Save original event
result.event = fetchEventResult.value
// Extract the blog details from the event
result.blog = extractBlogDetails(fetchEventResult.value)
} else if (fetchEventResult.status === 'rejected') {
@ -116,7 +123,7 @@ export const blogRouteLoader =
throw new Error('We are unable to find the blog on the relays')
}
// Check the lateast blog events
// Check the latest blog events
const fetchEventsResult = settled[1]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
@ -136,10 +143,28 @@ export const blogRouteLoader =
if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteLists && muteLists.value) {
if (result.blog && result.blog.aTag) {
// Show user or admin post warning if any mute list includes either post or author
if (
muteLists.value.user.replaceableEvents.includes(
result.blog.aTag
) ||
(result.blog.author &&
muteLists.value.user.authors.includes(result.blog.author))
) {
result.postWarning = 'user'
}
if (
muteLists.value.admin.replaceableEvents.includes(
result.blog.aTag
) ||
(result.blog.author &&
muteLists.value.admin.authors.includes(result.blog.author))
) {
result.postWarning = 'admin'
}
if (
muteLists.value.user.replaceableEvents.includes(result.blog.aTag)
) {
result.isBlocked = true
@ -147,8 +172,6 @@ export const blogRouteLoader =
}
// Moderate the latest
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === pubkey
const isUnmoderatedFully =

View File

@ -14,7 +14,8 @@ import {
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
useNSFWList,
useRepostList
} from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types'
@ -257,18 +258,14 @@ const DisplayLatestMods = () => {
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const repostList = useRepostList()
useDidMount(() => {
fetchMods({ source: window.location.host })
.then((mods) => {
// Sort by the latest (published_at descending)
mods.sort((a, b) => b.published_at - a.published_at)
const wotFilteredMods = mods.filter(
(mod) =>
isInWoT(siteWot, siteWotLevel, mod.author) ||
isInWoT(userWot, userWotLevel, mod.author)
)
setLatestMods(wotFilteredMods)
setLatestMods(mods)
})
.finally(() => {
setIsFetchingLatestMods(false)
@ -287,11 +284,36 @@ const DisplayLatestMods = () => {
!mutedAuthors.includes(mod.author) &&
!mutedEvents.includes(mod.aTag) &&
!nsfwList.includes(mod.aTag) &&
!mod.nsfw
!mod.nsfw &&
isInWoT(siteWot, siteWotLevel, mod.author) &&
isInWoT(userWot, userWotLevel, mod.author)
)
// Add repost tag if missing
for (let i = 0; i < filtered.length; i++) {
const mod = filtered[i]
const isMissingRepostTag =
!mod.repost && mod.aTag && repostList.includes(mod.aTag)
if (isMissingRepostTag) {
mod.repost = true
}
}
return filtered.slice(0, 4)
}, [muteLists, nsfwList, latestMods])
}, [
latestMods,
muteLists.admin.authors,
muteLists.admin.replaceableEvents,
muteLists.user.authors,
muteLists.user.replaceableEvents,
nsfwList,
repostList,
siteWot,
siteWotLevel,
userWot,
userWotLevel
])
return (
<div className='IBMSecMain IBMSMListWrapper'>

View File

@ -43,11 +43,15 @@ export const modRouteAction =
}
const userState = store.getState().user
let hexPubkey: string
let hexPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {

View File

@ -1,9 +1,11 @@
import FsLightbox from 'fslightbox-react'
import { nip19 } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Outlet,
Link as ReactRouterLink,
useLoaderData,
useNavigate,
useNavigation,
useParams,
useSubmit
@ -11,8 +13,13 @@ import {
import { toast } from 'react-toastify'
import { BlogCard } from '../../components/BlogCard'
import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector, useBodyScrollDisable } from '../../hooks'
import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useLocalStorage
} from '../../hooks'
import { appRoutes, getGamePageRoute, getModsEditPageRoute } from '../../routes'
import '../../styles/comments.css'
import '../../styles/downloads.css'
import '../../styles/innerPage.css'
@ -23,12 +30,22 @@ import '../../styles/styles.css'
import '../../styles/tabs.css'
import '../../styles/tags.css'
import '../../styles/write.css'
import { DownloadUrl, ModPageLoaderResult } from '../../types'
import {
DownloadUrl,
ModDetails,
ModFormState,
ModPageLoaderResult,
ModPermissions,
MODPERMISSIONS_CONF,
MODPERMISSIONS_DESC
} from '../../types'
import {
capitalizeEachWord,
checkUrlForFile,
copyTextToClipboard,
downloadFile,
getFilenameFromUrl
getFilenameFromUrl,
isValidUrl
} from '../../utils'
import { Comments } from '../../components/comment'
import { PublishDetails } from 'components/Internal/PublishDetails'
@ -37,9 +54,10 @@ import { ReportPopup } from 'components/ReportPopup'
import { Spinner } from 'components/Spinner'
import { RouterLoadingSpinner } from 'components/LoadingSpinner'
import { OriginalAuthor } from 'components/OriginalAuthor'
import DOMPurify from 'dompurify'
import TurndownService from 'turndown'
import { Viewer } from 'components/Markdown/Viewer'
import { PostWarnings } from 'components/PostWarning'
import { DownloadDetailsPopup } from 'components/DownloadDetailsPopup'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
const MOD_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -53,10 +71,10 @@ const MOD_REPORT_REASONS = [
]
export const ModPage = () => {
const { mod } = useLoaderData() as ModPageLoaderResult
const { mod, postWarning } = useLoaderData() as ModPageLoaderResult
// We can get author right away from naddr, no need to wait for mod data
const { naddr } = useParams()
const { naddr, nevent } = useParams()
let author = mod?.author
if (naddr && !author) {
try {
@ -69,22 +87,14 @@ export const ModPage = () => {
const [commentCount, setCommentCount] = useState(0)
const oldDownloadListRef = useRef<HTMLDivElement>(null)
const handleViewOldLinks = () => {
if (oldDownloadListRef.current) {
// Toggle styles
if (oldDownloadListRef.current.style.height === '0px') {
// Enable styles
oldDownloadListRef.current.style.padding = ''
oldDownloadListRef.current.style.height = ''
oldDownloadListRef.current.style.border = ''
} else {
// Disable styles
oldDownloadListRef.current.style.padding = '0'
oldDownloadListRef.current.style.height = '0'
oldDownloadListRef.current.style.border = 'unset'
}
const navigate = useNavigate()
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(
(mod?.nsfw ?? false) && !confirmNsfw
)
const handleConfirm = (confirm: boolean) => {
if (!confirm) {
navigate(appRoutes.home)
}
}
@ -100,18 +110,8 @@ export const ModPage = () => {
<>
<div className='IBMSMSplitMainBigSideSec'>
<Game />
<Body
featuredImageUrl={mod.featuredImageUrl}
title={mod.title}
body={mod.body}
game={mod.game}
screenshotsUrls={mod.screenshotsUrls}
tags={mod.tags}
LTags={mod.LTags}
nsfw={mod.nsfw}
repost={mod.repost}
originalAuthor={mod.originalAuthor}
/>
{postWarning && <PostWarnings type={postWarning} />}
<Body {...mod} />
<Interactions
addressable={mod}
commentCount={commentCount}
@ -127,43 +127,23 @@ export const ModPage = () => {
<h4 className='IBMSMSMBSSDownloadsTitle'>
Mod Download
</h4>
{postWarning && <PostWarnings type={postWarning} />}
{mod.downloadUrls.length > 0 && (
<div className='IBMSMSMBSSDownloadsPrime'>
<Download {...mod.downloadUrls[0]} />
</div>
)}
{mod.downloadUrls.length > 1 && (
<>
<div className='IBMSMSMBSSDownloadsActions'>
<button
className='btn btnMain'
id='viewOldLinks'
type='button'
onClick={handleViewOldLinks}
>
View other links
</button>
</div>
<div
ref={oldDownloadListRef}
id='oldDownloadList'
className='IBMSMSMBSSDownloads'
style={{
padding: 0,
height: '0px',
border: 'unset'
}}
>
{mod.downloadUrls
.slice(1)
.map((download, index) => (
<Download
key={`downloadUrl-${index}`}
{...download}
/>
))}
</div>
</>
<div className='IBMSMSMBSSDownloads'>
{mod.downloadUrls
.slice(1)
.map((download, index) => (
<Download
key={`downloadUrl-${index}`}
{...download}
/>
))}
</div>
)}
</div>
</div>
@ -182,6 +162,13 @@ export const ModPage = () => {
{typeof author !== 'undefined' && (
<ProfileSection pubkey={author} />
)}
<Outlet key={nevent} />
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</div>
</div>
</div>
@ -196,6 +183,7 @@ const Game = () => {
const { mod, isAddedToNSFW, isBlocked, isRepost } =
useLoaderData() as ModPageLoaderResult
const userState = useAppSelector((state) => state.user)
const isLoggedIn = userState.auth && userState.user?.pubkey !== 'undefined'
const [showReportPopUp, setShowReportPopUp] = useState<number | undefined>()
useBodyScrollDisable(!!showReportPopUp)
@ -338,25 +326,27 @@ const Game = () => {
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => {
setShowReportPopUp(Date.now())
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
{isLoggedIn && (
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => {
setShowReportPopUp(Date.now())
}}
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleBlock}
@ -423,18 +413,22 @@ const Game = () => {
)
}
type BodyProps = {
featuredImageUrl: string
title: string
body: string
game: string
screenshotsUrls: string[]
tags: string[]
LTags: string[]
nsfw: boolean
repost: boolean
originalAuthor?: string
}
type BodyProps = Pick<
ModDetails,
| 'featuredImageUrl'
| 'title'
| 'body'
| 'game'
| 'screenshotsUrls'
| 'tags'
| 'LTags'
| 'nsfw'
| 'repost'
| 'originalAuthor'
| keyof ModPermissions
| 'publisherNotes'
| 'extraCredits'
>
const Body = ({
featuredImageUrl,
@ -446,19 +440,19 @@ const Body = ({
LTags,
nsfw,
repost,
originalAuthor
originalAuthor,
otherAssets,
uploadPermission,
modPermission,
convPermission,
assetUsePermission,
assetUseComPermission,
publisherNotes,
extraCredits
}: BodyProps) => {
const COLLAPSED_MAX_SIZE = 250
const postBodyRef = useRef<HTMLDivElement>(null)
const viewFullPostBtnRef = useRef<HTMLDivElement>(null)
const markdown = useMemo(() => {
const sanitized = DOMPurify.sanitize(body)
const turndown = new TurndownService()
turndown.keep(['sup', 'sub'])
return turndown.turndown(sanitized)
}, [body])
const [lightBoxController, setLightBoxController] = useState({
toggler: false,
slide: 1
@ -508,7 +502,7 @@ const Body = ({
padding: '10px 18px'
}}
>
<Viewer markdown={markdown} />
<Viewer markdown={body} />
<div ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
<div className='IBMSMSMBSSPostBodyHideText'>
<p onClick={viewFullPost}>Read Full</p>
@ -526,6 +520,16 @@ const Body = ({
/>
))}
</div>
<ExtraDetails
otherAssets={otherAssets}
uploadPermission={uploadPermission}
modPermission={modPermission}
convPermission={convPermission}
assetUsePermission={assetUsePermission}
assetUseComPermission={assetUseComPermission}
publisherNotes={publisherNotes}
extraCredits={extraCredits}
/>
<div className='IBMSMSMBSSTags'>
{nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
@ -608,15 +612,29 @@ const Body = ({
)
}
const Download = ({
url,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote
}: DownloadUrl) => {
const Download = (props: DownloadUrl) => {
const { url, title, malwareScanLink } = props
const [showAuthDetails, setShowAuthDetails] = useState(false)
const [showNotice, setShowNotice] = useState(false)
const [showScanNotice, setShowCanNotice] = useState(false)
useDidMount(async () => {
const isFile = await checkUrlForFile(url)
setShowNotice(!isFile)
// Check the malware scan url
// if it's valid URL
// if it contains sha256
// if it differs from download link
setShowCanNotice(
!(
malwareScanLink &&
isValidUrl(malwareScanLink) &&
/\b[a-fA-F0-9]{64}\b/.test(malwareScanLink) &&
malwareScanLink !== url
)
)
})
const handleDownload = () => {
// Get the filename from the URL
@ -627,6 +645,9 @@ const Download = ({
return (
<div className='IBMSMSMBSSDownloadsElement'>
{typeof title !== 'undefined' && title !== '' && (
<span className='IBMSMSMBSSDownloadsElementDtitle'>{title}</span>
)}
<div className='IBMSMSMBSSDownloadsElementInside'>
<button
className='btn btnMain IBMSMSMBSSDownloadsElementBtn'
@ -636,6 +657,25 @@ const Download = ({
Download
</button>
</div>
{showNotice && (
<div className='IBMSMSMBSSNote'>
<p>
Notice: The creator has provided a download link that doesn&#39;t
download the files immediately, but rather redirects you to a
different site.
<br />
</p>
</div>
)}
{showScanNotice && (
<div className='IBMSMSMBSSWarning'>
<p>
The mod poster hasn't provided a malware scan report for these
files. Be careful.
<br />
</p>
</div>
)}
{/*temporarily commented out the WoT rating for download links within a mod post
<div className='IBMSMSMBSSDownloadsElementInside'>
<p>Ratings (WIP):</p>
@ -810,56 +850,10 @@ const Download = ({
Authentication Details
</p>
{showAuthDetails && (
<div className='IBMSMSMBSSDownloadsElementInsideAltTable'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Download URL</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{url}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>SHA-256 hash</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{hash}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Signature from</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{signatureKey}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Scan</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{malwareScanLink}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Mod Version</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{modVersion}</p>
</div>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
<p>Note</p>
</div>
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
<p>{customNote}</p>
</div>
</div>
</div>
<DownloadDetailsPopup
{...props}
handleClose={() => setShowAuthDetails(false)}
/>
)}
</div>
</div>
@ -884,3 +878,122 @@ const DisplayModAuthorBlogs = () => {
</div>
)
}
type ExtraDetailsProps = ModPermissions &
Pick<ModFormState, 'publisherNotes' | 'extraCredits'>
const ExtraDetails = ({
publisherNotes,
extraCredits,
...rest
}: ExtraDetailsProps) => {
const extraBoxRef = useRef<HTMLDivElement>(null)
if (
typeof publisherNotes === 'undefined' &&
typeof extraCredits === 'undefined' &&
Object.values(rest).every((v) => typeof v === 'undefined')
) {
return null
}
const handleClick = () => {
if (extraBoxRef.current) {
if (extraBoxRef.current.style.display === '') {
extraBoxRef.current.style.display = 'none'
} else {
extraBoxRef.current.style.display = ''
}
}
}
return (
<div className='IBMSMSMBSSExtra'>
<button
className='btn btnMain IBMSMSMBSSExtraBtn'
type='button'
onClick={handleClick}
>
Permissions &amp; Details
</button>
<div
className='IBMSMSMBSSExtraBox'
ref={extraBoxRef}
style={{
display: 'none'
}}
>
<div className='IBMSMSMBSSExtraBoxElementWrapper'>
{Object.keys(MODPERMISSIONS_CONF).map((k) => {
const permKey = k as keyof ModPermissions
const confKey = k as keyof typeof MODPERMISSIONS_CONF
const modPermission = MODPERMISSIONS_CONF[confKey]
const value = rest[permKey]
if (typeof value === 'undefined') return null
const text = MODPERMISSIONS_DESC[`${permKey}_${value}`]
return (
<div className='IBMSMSMBSSExtraBoxElement' key={k}>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>{modPermission.header}</p>
{value ? (
<div className='IBMSMSMBSSExtraBoxElementColMark IBMSMSMBSSExtraBoxElementColMarkGreen'>
<svg
className='IBMSMSMSSS_Author_Top_Icon'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM371.8 211.8C382.7 200.9 382.7 183.1 371.8 172.2C360.9 161.3 343.1 161.3 332.2 172.2L224 280.4L179.8 236.2C168.9 225.3 151.1 225.3 140.2 236.2C129.3 247.1 129.3 264.9 140.2 275.8L204.2 339.8C215.1 350.7 232.9 350.7 243.8 339.8L371.8 211.8z'></path>
</svg>
</div>
) : (
<div className='IBMSMSMBSSExtraBoxElementColMark IBMSMSMBSSExtraBoxElementColMarkRed'>
<svg
className='IBMSMSMSSS_Author_Top_Icon'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM175 208.1L222.1 255.1L175 303C165.7 312.4 165.7 327.6 175 336.1C184.4 346.3 199.6 346.3 208.1 336.1L255.1 289.9L303 336.1C312.4 346.3 327.6 346.3 336.1 336.1C346.3 327.6 346.3 312.4 336.1 303L289.9 255.1L336.1 208.1C346.3 199.6 346.3 184.4 336.1 175C327.6 165.7 312.4 165.7 303 175L255.1 222.1L208.1 175C199.6 165.7 184.4 165.7 175 175C165.7 184.4 165.7 199.6 175 208.1V208.1z'></path>
</svg>
</div>
)}
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<p>
{text}
<br />
</p>
</div>
</div>
)
})}
{typeof publisherNotes !== 'undefined' && publisherNotes !== '' && (
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Publisher Notes</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<p>{publisherNotes}</p>
</div>
</div>
)}
{typeof extraCredits !== 'undefined' && extraCredits !== '' && (
<div className='IBMSMSMBSSExtraBoxElement'>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
<p>Extra Credits</p>
</div>
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
<p>{extraCredits}</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -16,15 +16,17 @@ import {
DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails,
extractModData,
getFallbackPubkey,
getLocalStorageItem,
getReportingSet,
log,
LogType
LogType,
timeout
} from 'utils'
export const modRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
async ({ params, request }: LoaderFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
@ -46,7 +48,17 @@ export const modRouteLoader =
}
const userState = store.getState().user
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
const loggedInUserPubkey =
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
// Check if editing and the user is the original author
// Redirect if NOT
const url = new URL(request.url)
const isEditMode = url.pathname.includes('edit-mod')
if (isEditMode && loggedInUserPubkey !== pubkey) {
return redirect(appRoutes.mods)
}
try {
// Set up the filters
@ -82,8 +94,8 @@ export const modRouteLoader =
// Parallel fetch mod event, latest events, mute, nsfw, repost lists
const settled = await Promise.allSettled([
ndkContext.fetchEvent(modFilter),
ndkContext.fetchEvents(latestFilter),
Promise.race([ndkContext.fetchEvent(modFilter), timeout(2000)]),
Promise.race([ndkContext.fetchEvents(latestFilter), timeout(2000)]),
ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
@ -91,6 +103,7 @@ export const modRouteLoader =
const result: ModPageLoaderResult = {
mod: undefined,
event: undefined,
latest: [],
isAddedToNSFW: false,
isBlocked: false,
@ -100,6 +113,9 @@ export const modRouteLoader =
// Check the mod event result
const fetchEventResult = settled[0]
if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) {
// Save original event
result.event = fetchEventResult.value
// Extract the mod data from the event
result.mod = extractModData(fetchEventResult.value)
} else if (fetchEventResult.status === 'rejected') {
@ -117,7 +133,7 @@ export const modRouteLoader =
throw new Error('We are unable to find the mod on the relays')
}
// Check the lateast blog events
// Check the latest blog events
const fetchEventsResult = settled[1]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
@ -135,10 +151,27 @@ export const modRouteLoader =
if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteLists && muteLists.value) {
if (result.mod && result.mod.aTag) {
// Show user or admin post warning if any mute list includes either post or author
if (
muteLists.value.user.replaceableEvents.includes(
result.mod.aTag
) ||
muteLists.value.user.authors.includes(result.mod.author)
) {
result.postWarning = 'user'
}
if (
muteLists.value.admin.replaceableEvents.includes(
result.mod.aTag
) ||
muteLists.value.admin.authors.includes(result.mod.author)
) {
result.postWarning = 'admin'
}
// Check if user has blocked this profile
if (
muteLists.value.user.replaceableEvents.includes(result.mod.aTag)
) {
result.isBlocked = true
@ -146,8 +179,6 @@ export const modRouteLoader =
}
// Moderate the latest
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === pubkey
const isUnmoderatedFully =

View File

@ -1,7 +1,13 @@
import { NDKContextType } from 'contexts/NDKContext'
import { store } from 'store'
import { MuteLists } from 'types'
import { getReportingSet, CurationSetIdentifiers, log, LogType } from 'utils'
import {
getReportingSet,
CurationSetIdentifiers,
log,
LogType,
getFallbackPubkey
} from 'utils'
export interface ModsPageLoaderResult {
muteLists: {
@ -31,15 +37,11 @@ export const modsRouteLoader = (ndkContext: NDKContextType) => async () => {
// Get the current state
const userState = store.getState().user
// Check if current user is logged in
let userPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
userPubkey = userState.user.pubkey as string
}
const loggedInUserPubkey =
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
const settled = await Promise.allSettled([
ndkContext.getMuteLists(userPubkey),
ndkContext.getMuteLists(loggedInUserPubkey),
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
])

View File

@ -30,6 +30,8 @@ import {
copyTextToClipboard,
DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails,
log,
LogType,
now,
npubToHex,
scrollIntoView,
@ -46,7 +48,6 @@ export const ProfilePage = () => {
profilePubkey,
profile,
isBlocked: _isBlocked,
isOwnProfile,
repostList,
muteLists,
nsfwList
@ -60,6 +61,11 @@ export const ProfilePage = () => {
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const [showReportPopUp, setShowReportPopUp] = useState(false)
const isLoggedIn = userState.auth && userState.user?.pubkey !== 'undefined'
const isOwnProfile =
userState.auth &&
userState.user?.pubkey &&
userState.user.pubkey === profilePubkey
const [isBlocked, setIsBlocked] = useState(_isBlocked)
const handleBlock = async () => {
@ -68,7 +74,7 @@ export const ProfilePage = () => {
return
}
let userHexKey: string
let userHexKey: string | undefined
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -76,7 +82,11 @@ export const ProfilePage = () => {
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -373,23 +383,25 @@ export const ProfilePage = () => {
</a>
{!isOwnProfile && (
<>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportUser'
onClick={() => setShowReportPopUp(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
{isLoggedIn && (
<a
className='dropdown-item dropdownMainMenuItem'
id='reportUser'
onClick={() => setShowReportPopUp(true)}
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z'></path>
</svg>
Report
</a>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={isBlocked ? handleUnblock : handleBlock}
@ -506,11 +518,15 @@ const ReportUserPopup = ({
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
let userHexKey: string
let userHexKey: string | undefined
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
try {
userHexKey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!userHexKey) {
@ -661,7 +677,7 @@ const ReportUserPopup = ({
}
const ProfileTabBlogs = () => {
const { profile, muteLists, nsfwList } =
const { profilePubkey, muteLists, nsfwList } =
useLoaderData() as ProfilePageLoaderResult
const navigation = useNavigation()
const { fetchEvents } = useNDKContext()
@ -669,7 +685,7 @@ const ProfileTabBlogs = () => {
const [isLoading, setIsLoading] = useState(true)
const blogfilter: NDKFilter = useMemo(() => {
const filter: NDKFilter = {
authors: [profile?.pubkey as string],
authors: [profilePubkey],
kinds: [kinds.LongFormArticle]
}
@ -683,13 +699,13 @@ const ProfileTabBlogs = () => {
}
return filter
}, [filterOptions.nsfw, filterOptions.source, profile?.pubkey])
}, [filterOptions.nsfw, filterOptions.source, profilePubkey])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>([])
useEffect(() => {
if (profile) {
if (profilePubkey) {
// Initial blog fetch, go beyond limit to check for next
const filter: NDKFilter = {
...blogfilter,
@ -704,7 +720,7 @@ const ProfileTabBlogs = () => {
setIsLoading(false)
})
}
}, [blogfilter, fetchEvents, profile])
}, [blogfilter, fetchEvents, profilePubkey])
const handleNext = useCallback(() => {
if (isLoading) return
@ -758,9 +774,11 @@ const ProfileTabBlogs = () => {
let _blogs = blogs || []
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === profile?.pubkey
userState.user?.pubkey && userState.user.pubkey === profilePubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
const isOnlyBlocked =
filterOptions.moderated === ModeratedFilter.Only_Blocked
// Add nsfw tag to blogs included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
@ -776,9 +794,16 @@ const ProfileTabBlogs = () => {
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
if (isOnlyBlocked && isAdmin) {
_blogs = _blogs.filter(
(b) =>
muteLists.admin.authors.includes(b.author!) ||
muteLists.admin.replaceableEvents.includes(b.aTag!)
)
} else if (isUnmoderatedFully && (isAdmin || isOwner)) {
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
} else {
_blogs = _blogs.filter(
(b) =>
!muteLists.admin.authors.includes(b.author!) &&
@ -815,7 +840,7 @@ const ProfileTabBlogs = () => {
muteLists.user.authors,
muteLists.user.replaceableEvents,
nsfwList,
profile?.pubkey,
profilePubkey,
userState.user?.npub,
userState.user?.pubkey
])
@ -826,10 +851,7 @@ const ProfileTabBlogs = () => {
<LoadingSpinner desc={'Loading...'} />
)}
<BlogsFilter
filterKey={'filter-blog'}
author={profile?.pubkey as string}
/>
<BlogsFilter filterKey={'filter-blog'} author={profilePubkey} />
<div className='IBMSMList IBMSMListAlt'>
{moderatedAndSortedBlogs.map((b) => (

View File

@ -6,6 +6,7 @@ import { store } from 'store'
import { MuteLists, UserProfile } from 'types'
import {
CurationSetIdentifiers,
getFallbackPubkey,
getReportingSet,
log,
LogType,
@ -16,7 +17,6 @@ export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile
isBlocked: boolean
isOwnProfile: boolean
muteLists: {
admin: MuteLists
user: MuteLists
@ -58,21 +58,17 @@ export const profileRouteLoader =
// Get the current state
const userState = store.getState().user
// Check if current user is logged in
let userPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
userPubkey = userState.user.pubkey as string
}
const loggedInUserPubkey =
(userState?.user?.pubkey as string | undefined) || getFallbackPubkey()
// Redirect if profile naddr is missing
// - home if user is not logged
let profileRoute = appRoutes.home
if (!profilePubkey && userPubkey) {
if (!profilePubkey && loggedInUserPubkey) {
// - own profile
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userPubkey
pubkey: loggedInUserPubkey
})
)
}
@ -83,7 +79,6 @@ export const profileRouteLoader =
profilePubkey: profilePubkey,
profile: {},
isBlocked: false,
isOwnProfile: false,
muteLists: {
admin: {
authors: [],
@ -98,14 +93,9 @@ export const profileRouteLoader =
repostList: []
}
// Check if user the user is logged in
if (userState.auth && userState.user?.pubkey) {
result.isOwnProfile = userState.user.pubkey === profilePubkey
}
const settled = await Promise.allSettled([
ndkContext.findMetadata(profilePubkey),
ndkContext.getMuteLists(userPubkey),
ndkContext.getMuteLists(loggedInUserPubkey),
getReportingSet(CurationSetIdentifiers.NSFW, ndkContext),
getReportingSet(CurationSetIdentifiers.Repost, ndkContext)
])

View File

@ -39,6 +39,7 @@ import {
scrollIntoView
} from 'utils'
import { useCuratedSet } from 'hooks/useCuratedSet'
import dedup from 'utils/nostr'
enum SearchKindEnum {
Mods = 'Mods',
@ -173,12 +174,15 @@ const Filters = React.memo(() => {
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
if (item === ModeratedFilter.Only_Blocked && !isAdmin) {
return null
}
if (item === ModeratedFilter.Unmoderated_Fully && !isAdmin) {
return null
}
return (
@ -438,9 +442,16 @@ const UsersResult = ({
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
moderationFilter === ModeratedFilter.Unmoderated_Fully
const isOnlyBlocked = moderationFilter === ModeratedFilter.Only_Blocked
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
if (!(isAdmin && isUnmoderatedFully)) {
if (isOnlyBlocked && isAdmin) {
filtered = filtered.filter((profile) =>
muteLists.admin.authors.includes(profile.pubkey as string)
)
} else if (isUnmoderatedFully && isAdmin) {
// Only apply filtering if the user is not an admin
// or the admin has not selected "Unmoderated Fully"
} else {
filtered = filtered.filter(
(profile) => !muteLists.admin.authors.includes(profile.pubkey as string)
)
@ -508,6 +519,11 @@ const GamesResult = ({ searchTerm }: GamesResultProps) => {
return (
<>
{searchTerm !== '' && filteredGames.length === 0 && (
<div className='IBMSecMain IBMSMListWrapper'>
Game not found. Send us a message where you can reach us to add it
</div>
)}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
{filteredGames
@ -521,20 +537,14 @@ const GamesResult = ({ searchTerm }: GamesResultProps) => {
))}
</div>
</div>
<Pagination
page={page}
disabledNext={filteredGames.length <= page * MAX_GAMES_PER_PAGE}
handlePrev={handlePrev}
handleNext={handleNext}
/>
{searchTerm !== '' && filteredGames.length > MAX_GAMES_PER_PAGE && (
<Pagination
page={page}
disabledNext={filteredGames.length <= page * MAX_GAMES_PER_PAGE}
handlePrev={handlePrev}
handleNext={handleNext}
/>
)}
</>
)
}
function dedup(event1: NDKEvent, event2: NDKEvent) {
// return the newest of the two
if (event1.created_at! > event2.created_at!) {
return event1
}
return event2
}

View File

@ -55,12 +55,16 @@ export const PreferencesSetting = () => {
const handleSave = async () => {
setIsSaving(true)
let hexPubkey: string
let hexPubkey: string | undefined
if (user?.pubkey) {
hexPubkey = user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
}
}
if (!hexPubkey) {

View File

@ -5,14 +5,23 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getModPageRoute } from 'routes'
import { store } from 'store'
import { FormErrors, ModFormState } from 'types'
import {
FormErrors,
ModFormState,
MODPERMISSIONS_CONF,
SubmitModActionResult,
TimeoutError
} from 'types'
import {
isReachable,
isValidImageUrl,
isValidUrl,
log,
LogType,
now
MOD_DRAFT_CACHE_KEY,
now,
removeLocalStorageItem,
timeout
} from 'utils'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../../constants'
@ -49,29 +58,31 @@ export const submitModRouteAction =
// Check for errors
const formErrors = await validateState(formState)
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Return early if there are any errors
if (Object.keys(formErrors).length) {
return {
type: 'validation',
error: formErrors
} as SubmitModActionResult
}
const currentTimeStamp = now()
// Check if we are editing or this is a new mob
const { naddr } = params
// Check if we are editing or this is a new mob
const isEditing = naddr && request.method === 'PUT'
const uuid = formState.dTag || uuidv4()
const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const published_at = formState.published_at || currentTimeStamp
const tags = [
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
isEditing
? formState.published_at.toString()
: currentTimeStamp.toString()
],
['published_at', published_at.toString()],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
@ -85,8 +96,41 @@ export const submitModRouteAction =
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
],
[
'otherAssets',
formState.otherAssets?.toString() ??
MODPERMISSIONS_CONF.otherAssets.default.toString()
],
[
'uploadPermission',
formState.uploadPermission?.toString() ??
MODPERMISSIONS_CONF.uploadPermission.toString()
],
[
'modPermission',
formState.modPermission?.toString() ??
MODPERMISSIONS_CONF.modPermission.toString()
],
[
'convPermission',
formState.convPermission?.toString() ??
MODPERMISSIONS_CONF.convPermission.toString()
],
[
'assetUsePermission',
formState.assetUsePermission?.toString() ??
MODPERMISSIONS_CONF.assetUsePermission.toString()
],
[
'assetUseComPermission',
formState.assetUseComPermission?.toString() ??
MODPERMISSIONS_CONF.assetUseComPermission.toString()
],
['publisherNotes', formState.publisherNotes ?? ''],
['extraCredits', formState.extraCredits ?? '']
]
if (formState.repost && formState.originalAuthor) {
tags.push(['originalAuthor', formState.originalAuthor])
}
@ -129,30 +173,52 @@ export const submitModRouteAction =
}
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
const publishedOnRelays = await ndkContext.publish(ndkEvent)
// Publishing a mod sometime hangs (ndk.publish has internal timeout of 10s)
// Make sure to actually throw a timeout error (30s)
try {
const publishedOnRelays = await Promise.race([
ndkContext.publish(ndkEvent),
timeout(30000)
])
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
!isEditing && removeLocalStorageItem(MOD_DRAFT_CACHE_KEY)
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
return redirect(getModPageRoute(naddr))
return redirect(getModPageRoute(naddr))
}
} catch (error) {
if (error instanceof TimeoutError) {
const result: SubmitModActionResult = {
type: 'timeout',
data: {
dTag: uuid,
aTag,
published_at: published_at
}
}
return result
}
// Rethrow non-timeout for general catch
throw error
}
} catch (error) {
log(true, LogType.Error, 'Failed to sign the event!', error)
toast.error('Failed to sign the event!')
return null
}
@ -209,13 +275,6 @@ const validateState = async (
}
}
if (
formState.repost &&
(!formState.originalAuthor || formState.originalAuthor === '')
) {
errors.originalAuthor = 'Original author field can not be empty'
}
if (!formState.tags || formState.tags === '') {
errors.tags = 'Tags field can not be empty'
}
@ -231,6 +290,19 @@ const validateState = async (
errors.downloadUrls![i] = 'Download url must be valid and reachable'
}
if (
downloadUrl.mediaUrl &&
downloadUrl.mediaUrl.trim() !== '' &&
(!isValidUrl(downloadUrl.mediaUrl) ||
!isValidImageUrl(downloadUrl.mediaUrl) ||
!(await isReachable(downloadUrl.mediaUrl)))
) {
if (!errors.downloadUrls)
errors.downloadUrls = Array(formState.downloadUrls.length)
errors.downloadUrls![i] = 'Media URLs must be valid and reachable image'
}
}
}

View File

@ -1,4 +1,4 @@
import { LoadingSpinner } from 'components/LoadingSpinner'
import { LoadingSpinner, TimerLoadingSpinner } from 'components/LoadingSpinner'
import { ModForm } from 'components/ModForm'
import { ProfileSection } from 'components/ProfileSection'
import { useAppSelector } from 'hooks'
@ -24,7 +24,9 @@ export const SubmitModPage = () => {
<LoadingSpinner desc='Fetching mod details from relays' />
)}
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing mod to relays' />
<TimerLoadingSpinner timeoutMs={10000} countdownMs={30000}>
Publishing mod to relays
</TimerLoadingSpinner>
)}
<ModForm />
</div>

3
src/pages/supporters.tsx Normal file
View File

@ -0,0 +1,3 @@
export const SupportersPage = () => {
return <h2>WIP</h2>
}

View File

@ -3,12 +3,13 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { getBlogPageRoute } from 'routes'
import { BlogFormErrors, BlogEventSubmitForm, BlogEventEditForm } from 'types'
import {
BLOG_DRAFT_CACHE_KEY,
isReachable,
isValidImageUrl,
log,
LogType,
now,
parseFormData
removeLocalStorageItem
} from 'utils'
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
import { toast } from 'react-toastify'
@ -43,12 +44,9 @@ export const writeRouteAction =
}
// Get the form data from submit request
const formData = await request.formData()
// Parse the the data
const formSubmit = parseFormData<BlogEventSubmitForm | BlogEventEditForm>(
formData
)
const formSubmit = (await request.json()) as
| BlogEventSubmitForm
| BlogEventEditForm
// Check for errors
const formErrors = await validateFormData(formSubmit)
@ -80,7 +78,7 @@ export const writeRouteAction =
const tTags = formSubmit
.tags!.toLowerCase()
.split(',')
.map((t) => ['t', t])
.map((t) => ['t', t.trim()])
const tags = [
['d', uuid],
@ -95,7 +93,7 @@ export const writeRouteAction =
// Add NSFW tag, L label namespace standardized tag
// https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags
if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning'])
if (formSubmit.nsfw) tags.push(['L', 'content-warning'])
const unsignedEvent: UnsignedEvent = {
kind: kinds.LongFormArticle,
@ -128,6 +126,9 @@ export const writeRouteAction =
'\n'
)}`
)
!isEditing && removeLocalStorageItem(BLOG_DRAFT_CACHE_KEY)
const naddr = nip19.naddrEncode({
identifier: uuid,
pubkey: signedEvent.pubkey,

View File

@ -1,53 +1,123 @@
import { useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Form,
useActionData,
useLoaderData,
useNavigation
useNavigation,
useSubmit
} from 'react-router-dom'
import {
CheckboxFieldUncontrolled,
InputError,
InputFieldUncontrolled
} from '../../components/Inputs'
import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector } from '../../hooks'
import { BlogFormErrors, BlogPageLoaderResult } from 'types'
import '../../styles/innerPage.css'
import '../../styles/styles.css'
import '../../styles/write.css'
CheckboxField,
InputField,
InputFieldWithImageUpload
} from 'components/Inputs'
import { ProfileSection } from 'components/ProfileSection'
import { useAppSelector, useLocalCache } from 'hooks'
import {
BlogEventEditForm,
BlogEventSubmitForm,
BlogFormErrors,
BlogPageLoaderResult
} from 'types'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { AlertPopup } from 'components/AlertPopup'
import { Editor, EditorRef } from 'components/Markdown/Editor'
import { InputError } from 'components/Inputs/Error'
import { BLOG_DRAFT_CACHE_KEY, initializeBlogForm } from 'utils'
import 'styles/innerPage.css'
import 'styles/styles.css'
import 'styles/write.css'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
const data = useLoaderData() as BlogPageLoaderResult
const formErrors = useActionData() as BlogFormErrors
const navigation = useNavigation()
const submit = useSubmit()
const blog = data?.blog
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const [content, setContent] = useState(blog?.content || '')
const formRef = useRef<HTMLFormElement>(null)
// Enable cache for the new blog
const isEditing = typeof data?.blog !== 'undefined'
const [cache, setCache, clearCache] =
useLocalCache<BlogEventSubmitForm>(BLOG_DRAFT_CACHE_KEY)
const title = isEditing ? 'Edit blog post' : 'Submit a blog post'
const [formState, setFormState] = useState<
BlogEventSubmitForm | BlogEventEditForm
>(isEditing ? initializeBlogForm(blog) : cache ? cache : initializeBlogForm())
useEffect(() => {
!isEditing && setCache(formState)
}, [formState, isEditing, setCache])
const editorRef = useRef<EditorRef>(null)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => {
const handleReset = useCallback(() => {
setShowConfirmPopup(true)
}
const handleResetConfirm = (confirm: boolean) => {
setShowConfirmPopup(false)
}, [])
const handleResetConfirm = useCallback(
(confirm: boolean) => {
setShowConfirmPopup(false)
// Cancel if not confirmed
if (!confirm) return
// Cancel if not confirmed
if (!confirm) return
// Reset editor
if (blog?.content) {
editorRef.current?.setMarkdown(blog?.content)
}
const initialState = initializeBlogForm(blog)
formRef.current?.reset()
}
// Reset editor
editorRef.current?.setMarkdown(initialState.content)
setFormState(initialState)
// Clear cache
!isEditing && clearCache()
},
[blog, clearCache, isEditing]
)
const handleImageChange = useCallback((_name: string, value: string) => {
setFormState((prev) => ({
...prev,
image: value
}))
}, [])
const handleInputChange = useCallback((name: string, value: string) => {
setFormState((prevState) => ({
...prevState,
[name]: value
}))
}, [])
const handleEditorChange = useCallback(
(md: string) => {
handleInputChange('content', md)
},
[handleInputChange]
)
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target
setFormState((prevState) => ({
...prevState,
[name]: checked
}))
},
[]
)
const handleFormSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
submit(JSON.stringify(formState), {
method: isEditing ? 'put' : 'post',
encType: 'application/json'
})
},
[formState, isEditing, submit]
)
return (
<div className='InnerBodyMain'>
@ -64,80 +134,63 @@ export const WritePage = () => {
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing blog to relays' />
)}
<Form
ref={formRef}
className='IBMSMSMBS_Write'
method={blog ? 'put' : 'post'}
>
<InputFieldUncontrolled
<form className='IBMSMSMBS_Write' onSubmit={handleFormSubmit}>
<InputField
label='Title'
name='title'
defaultValue={blog?.title}
value={formState.title}
error={formErrors?.title}
onChange={handleInputChange}
placeholder='Blog title'
/>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={content}
onChange={(md) => {
setContent(md)
}}
markdown={formState.content}
onChange={handleEditorChange}
/>
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{/* encode to keep the markdown formatting */}
<input
name='content'
hidden
value={encodeURIComponent(content)}
readOnly
/>
</div>
<InputFieldUncontrolled
<InputFieldWithImageUpload
label='Featured Image URL'
name='image'
inputMode='url'
defaultValue={blog?.image}
value={formState.image}
error={formErrors?.image}
onInputChange={handleImageChange}
placeholder='Image URL'
/>
<InputFieldUncontrolled
<InputField
label='Summary'
name='summary'
type='textarea'
defaultValue={blog?.summary}
value={formState.summary}
error={formErrors?.summary}
onChange={handleInputChange}
placeholder={'This is a quick description of my blog'}
/>
<InputFieldUncontrolled
<InputField
label='Tags'
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
placeholder='Tags'
name='tags'
defaultValue={blog?.tTags?.join(', ')}
value={formState.tags}
error={formErrors?.tags}
onChange={handleInputChange}
/>
<CheckboxFieldUncontrolled
<CheckboxField
label='This post is not safe for work (NSFW)'
name='nsfw'
defaultChecked={blog?.nsfw}
isChecked={formState.nsfw}
handleChange={handleCheckboxChange}
type='stylized'
/>
{typeof blog?.dTag !== 'undefined' && (
<input name='dTag' hidden value={blog.dTag} readOnly />
)}
{typeof blog?.rTag !== 'undefined' && (
<input name='rTag' hidden value={blog.rTag} readOnly />
)}
{typeof blog?.published_at !== 'undefined' && (
<input
name='published_at'
hidden
value={blog.published_at}
readOnly
/>
)}
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
@ -148,7 +201,7 @@ export const WritePage = () => {
navigation.state === 'submitting'
}
>
{blog ? 'Reset' : 'Clear fields'}
{isEditing ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
@ -169,13 +222,13 @@ export const WritePage = () => {
handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
blog
isEditing
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
/>
)}
</Form>
</form>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />

View File

@ -28,17 +28,21 @@ import { BlogPage } from '../pages/blog'
import { blogRouteLoader } from '../pages/blog/loader'
import { blogRouteAction } from '../pages/blog/action'
import { reportRouteAction } from '../actions/report'
import { BackupPage } from 'pages/backup'
import { SupportersPage } from 'pages/supporters'
import { commentsLoader } from 'loaders/comment'
import { CommentsPopup } from 'components/comment/CommentsPopup'
export const appRoutes = {
home: '/',
games: '/games',
game: '/game/:name',
mods: '/mods',
mod: '/mod/:naddr',
mod: '/mod/:naddr/',
modReport_actionOnly: '/mod/:naddr/report',
about: '/about',
blogs: '/blog',
blog: '/blog/:naddr',
blog: '/blog/:naddr/',
blogEdit: '/blog/:naddr/edit',
blogReport_actionOnly: '/blog/:naddr/report',
submitMod: '/submit-mod',
@ -51,7 +55,9 @@ export const appRoutes = {
settingsAdmin: '/settings-admin',
profile: '/profile/:nprofile?',
feed: '/feed',
notifications: '/notifications'
notifications: '/notifications',
backup: '/backup',
supporters: '/supporters'
}
export const getGamePageRoute = (name: string) =>
@ -94,6 +100,13 @@ export const routerWithNdkContext = (context: NDKContextType) =>
{
path: appRoutes.mod,
element: <ModPage />,
children: [
{
path: ':nevent',
element: <CommentsPopup />,
loader: commentsLoader(context)
}
],
loader: modRouteLoader(context),
action: modRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
@ -114,6 +127,13 @@ export const routerWithNdkContext = (context: NDKContextType) =>
{
path: appRoutes.blog,
element: <BlogPage />,
children: [
{
path: ':nevent',
element: <CommentsPopup />,
loader: commentsLoader(context)
}
],
loader: blogRouteLoader(context),
action: blogRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
@ -185,6 +205,14 @@ export const routerWithNdkContext = (context: NDKContextType) =>
}
]
},
{
path: appRoutes.backup,
element: <BackupPage />
},
{
path: appRoutes.supporters,
element: <SupportersPage />
},
{
path: '*',
element: <NotFoundPage />

36
src/styles/backup.css Normal file
View File

@ -0,0 +1,36 @@
.backupList {
width: 100%;
display: flex;
flex-direction: column;
grid-gap: 15px;
}
.backupListLink {
transition: ease 0.4s;
overflow: hidden;
padding: 15px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
border-radius: 10px;
/*border: solid 1px rgba(255,255,255,0.1);*/
position: relative;
color: rgba(255,255,255,0.75);
min-height: 150px;
display: flex;
flex-direction: column;
}
.backupListLink:hover {
transition: ease 0.4s;
text-decoration: unset;
color: rgb(255,255,255);
transform: scale(1.02);
}
.backupListLinkInside {
display: flex;
flex-direction: column;
grid-gap: 0px;
flex-grow: 1;
justify-content: end;
}

View File

@ -7,7 +7,7 @@
cursor: pointer;
border-radius: 15px;
overflow: hidden;
padding: 5px;
padding: 5px 5px 10px 5px;
}
.cardGameMainWrapperLink:hover {
@ -41,7 +41,7 @@
.cardGameMainTitle {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.5);
padding: 0 10px 10px 10px;
padding: 0 10px;
font-weight: bold;
display: -webkit-box;
-webkit-box-orient: vertical;

View File

@ -59,6 +59,7 @@
}
.IBMSMSMBSSCL_CBText {
white-space: pre-line;
}
.IBMSMSMBSSCL_CBTextStatus {
@ -475,7 +476,16 @@ hover {
}
.IBMSMSMBSSCL_CommentNoteRepliesTitle {
width: 100%;
margin: 0 0 15px 0;
color: rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: row;
justify-content: space-between;
}
.IBMSMSMBSSCL_CommentNoteRepliesTitleBtn {
font-size: 16px;
}
.IBMSMSMBSSCL_CAElementLoadWrapper {

View File

@ -9,9 +9,9 @@
width: 100%;
border-radius: 10px;
display: grid;
grid-template-columns: 1fr;
grid-template-columns: 1fr 1fr;
grid-gap: 15px;
border: solid 1px rgba(255,255,255,0.05);
border: solid 1px rgba(255, 255, 255, 0.05);
overflow: auto;
max-height: 550px;
padding: 15px;
@ -27,7 +27,7 @@
}
.IBMSMSMBSSDownloadsTitle {
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
}
.IBMSMSMBSSDownloadsElement {
@ -36,11 +36,11 @@
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
border: solid 1px rgba(255,255,255,0);
background: rgba(255,255,255,0.05);
border: solid 1px rgba(255, 255, 255, 0);
background: rgba(255, 255, 255, 0.05);
padding: 10px;
border-radius: 10px;
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
@ -50,7 +50,7 @@
}
.btnMain.IBMSMSMBSSDownloadsElementBtn {
background: rgba(255,255,255,0.05);
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
width: 100%;
}
@ -62,8 +62,8 @@
}
.btnMain.IBMSMSMBSSDownloadsElementBtn:hover {
background: rgba(255,255,255,0.1);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
}
.IBMSMSMBSSDownloadsElementInside {
@ -71,7 +71,7 @@
flex-direction: column;
justify-content: start;
align-items: start;
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
grid-gap: 10px;
}
@ -97,27 +97,30 @@
justify-content: center;
align-items: center;
width: 100%;
background: rgba(255,255,255,0);
background: rgba(255, 255, 255, 0);
overflow: hidden;
border-radius: 10px;
border: solid 1px rgba(255,255,255,0.05);
border: solid 1px rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.IBMSMSMBSSDEIReactionsElement:hover {
transition: ease 0.4s;
background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.75);
border: solid 1px rgba(255,255,255,0);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
border: solid 1px rgba(255, 255, 255, 0);
}
.IBMSMSMBSSDEIReactionsElement:hover > .IBMSMSMBSSDEIReactionsElementIconWrapper {
.IBMSMSMBSSDEIReactionsElement:hover
> .IBMSMSMBSSDEIReactionsElementIconWrapper {
transition: ease 0.4s;
background: rgba(255,255,255,0.05);
border-right: solid 1px rgba(255,255,255,0);
background: rgba(255, 255, 255, 0.05);
border-right: solid 1px rgba(255, 255, 255, 0);
}
.IBMSMSMBSSDEIReactionsElement:hover > .IBMSMSMBSSDEIReactionsElementIconWrapper > .IBMSMSMBSSDEIReactionsElementIcon {
.IBMSMSMBSSDEIReactionsElement:hover
> .IBMSMSMBSSDEIReactionsElementIconWrapper
> .IBMSMSMBSSDEIReactionsElementIcon {
transform: scale(1.1);
}
@ -147,9 +150,9 @@
justify-content: center;
align-items: center;
height: 100%;
background: rgba(255,255,255,0);
background: rgba(255, 255, 255, 0);
padding: 10px 5px;
border-right: solid 1px rgba(255,255,255,0.05);
border-right: solid 1px rgba(255, 255, 255, 0.05);
}
.IBMSMSMBSSDownloadsElementInsideDetails {
@ -160,17 +163,20 @@
}
.IBMSMSMBSSDEIReactionsElement.IBMSMSMBSSDEIReactionsElementActive {
background: rgba(255,255,255,0.05);
border: solid 1px rgba(255,255,255,0);
background: rgba(255, 255, 255, 0.05);
border: solid 1px rgba(255, 255, 255, 0);
}
.IBMSMSMBSSDEIReactionsElement.IBMSMSMBSSDEIReactionsElementActive > .IBMSMSMBSSDEIReactionsElementIconWrapper {
background: rgba(255,255,255,0.05);
border-right: solid 1px rgba(255,255,255,0);
.IBMSMSMBSSDEIReactionsElement.IBMSMSMBSSDEIReactionsElementActive
> .IBMSMSMBSSDEIReactionsElementIconWrapper {
background: rgba(255, 255, 255, 0.05);
border-right: solid 1px rgba(255, 255, 255, 0);
}
.IBMSMSMBSSDEIReactionsElement.IBMSMSMBSSDEIReactionsElementActive > .IBMSMSMBSSDEIReactionsElementIconWrapper > .IBMSMSMBSSDEIReactionsElementIcon {
color: rgba(255,255,255,0.75);
.IBMSMSMBSSDEIReactionsElement.IBMSMSMBSSDEIReactionsElementActive
> .IBMSMSMBSSDEIReactionsElementIconWrapper
> .IBMSMSMBSSDEIReactionsElementIcon {
color: rgba(255, 255, 255, 0.75);
}
.IBMSMSMBSSDownloadsActions {
@ -188,7 +194,7 @@
display: flex;
flex-direction: column;
border-radius: 10px;
border: solid 1px rgba(255,255,255,0.1);
border: solid 1px rgba(255, 255, 255, 0.1);
overflow: auto;
grid-gap: 1px;
}
@ -202,7 +208,7 @@
.IBMSMSMBSSDownloadsElementInsideAltTableRow:hover {
transition: ease 0.4s;
background: rgba(255,255,255,0.05);
background: rgba(255, 255, 255, 0.05);
}
@media (max-width: 576px) {
@ -216,12 +222,17 @@
text-align: start;
padding: 10px 15px;
}
.IBMSMSMBSSDownloadsElementInsideAltTableRowCol_Img {
width: 100%;
max-width: 300px;
border-radius: 6px;
}
.IBMSMSMBSSDownloadsElementInsideAltTableRowCol.IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst {
text-align: center;
font-weight: bold;
max-width: 200px;
background: rgba(255,255,255,0.05);
background: rgba(255, 255, 255, 0.05);
display: flex;
justify-content: center;
align-items: center;
@ -237,13 +248,16 @@
transition: ease 0.4s;
cursor: pointer;
font-weight: 400;
color: rgba(255,255,255,0.25);
color: rgba(255, 255, 255, 0.25);
}
.IBMSMSMBSSDownloadsElementInsideAltText:hover {
transition: ease 0.4s;
cursor: pointer;
font-weight: 600;
color: rgba(255,255,255,0.75);
color: rgba(255, 255, 255, 0.75);
}
.IBMSMSMBSSDownloadsElementDtitle {
color: rgb(255,255,255,0.5);
}

View File

@ -59,7 +59,7 @@
}
code {
background-color: mediumpurple;
background-color: #666;
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
@ -92,6 +92,8 @@
img {
background: #232323;
border-radius: 10px;
max-width: 100%;
height: auto;
}
}
.editor {
@ -100,10 +102,6 @@
min-height: 75px;
}
.viewer {
img {
max-width: 100%;
height: auto;
}
table {
table-layout: fixed;
width: 100%;
@ -144,7 +142,11 @@
}
}
.mdxeditor {
--baseBg: rgba(255, 255, 255, 0.05);
--baseBg: #262626;
}
.mdxeditor-toolbar {
top: 10px;
border: 1px solid rgb(255, 255, 255, 0.1);
}
.mdxeditor,
.mdxeditor-popup-container {

View File

@ -283,9 +283,132 @@
text-align: center;
}
.IBMSMSMBSSNote {
width: 100%;
border-radius: 8px;
padding: 10px 15px;
border: solid 2px rgba(255,255,255,0.1);
background: rgba(255,122,0,0.2);
color: rgba(255,255,255,0.95);
text-align: center;
}
.IBMSMSMBSSPostBodyHideText > * {
width: 100%;
text-align: center;
margin: 0;
padding: 10px;
}
.IBMSMSMBSSExtra {
width: 100%;
display: flex;
flex-direction: column;
grid-gap: 0px;
overflow: hidden;
border-radius: 8px;
background: rgba(0,0,0,0.1);
border: solid 1px rgba(255,255,255,0.1);
}
.btnMain.IBMSMSMBSSExtraBtn {
border-radius: 0px;
flex-direction: column;
grid-gap: 0px;
}
.IBMSMSMBSSExtraBox {
width: 100%;
padding: 10px;
display: flex;
flex-direction: column;
}
.IBMSMSMBSSExtraBoxElement {
width: 100%;
display: flex;
flex-direction: column;
grid-gap: 0px;
background: rgba(255,255,255,0.05);
overflow: hidden;
border-radius: 4px;
}
.IBMSMSMBSSExtraBoxElementCol {
display: flex;
flex-direction: row;
align-items: start;
color: rgba(255,255,255,0.5);
padding: 10px;
grid-gap: 5px;
}
.IBMSMSMBSSExtraBoxElementCol.IBMSMSMBSSExtraBoxElementColStart {
font-weight: bold;
background: rgba(255,255,255,0.05);
justify-content: space-between;
}
.IBMSMSMBSSExtraBoxElementCol.IBMSMSMBSSExtraBoxElementColSecond {
flex-grow: 1;
max-height: 300px;
overflow: auto;
flex-direction: column;
}
.IBMSMSMBSSExtraBoxElementWrapper {
width: 100%;
overflow: hidden;
border-radius: 4px;
display: flex;
flex-direction: column;
grid-gap: 10px;
}
.IBMSMSMBSSExtraBoxElementColMark {
color: white;
}
.IBMSMSMBSSExtraBoxElementColMark.IBMSMSMBSSExtraBoxElementColMarkGreen {
color: #6cff6c;
}
.IBMSMSMBSSExtraBoxElementColMark.IBMSMSMBSSExtraBoxElementColMarkRed {
color: tomato;
}
.IBMSMSMBSSExtraBoxElementColChoice {
width: 100%;
display: flex;
flex-direction: row;
grid-gap: 10px;
padding: 10px;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.IBMSMSMBSSExtraBoxElementColChoiceRadio {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1;
opacity: 0;
}
.IBMSMSMBSSExtraBoxElementColChoiceBox {
transition: ease 0.2s;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
border: solid 2px rgba(255,255,255,0);
border-radius: 4px;
}
.IBMSMSMBSSExtraBoxElementColChoiceRadio:checked + .IBMSMSMBSSExtraBoxElementColChoiceBox {
border: solid 2px rgba(255,255,255,0.5);
}

View File

@ -661,6 +661,7 @@ a:hover {
padding-right: 50px;
}
.successMain,
.errorMain {
width: 100%;
border-radius: 10px;
@ -671,13 +672,17 @@ a:hover {
flex-direction: row;
grid-gap: 10px;
}
.successMainColor,
.errorMainColor {
width: 5px;
border-radius: 2px;
}
.errorMainColor {
background: tomato;
}
.successMainColor {
background: #60ae60;
}
.errorMainText {
}
@ -705,3 +710,39 @@ a:hover {
transform: rotate(360deg);
}
}
.uploadBoxMain {
background: hsl(0deg 0% 0% / 10%);
border-radius: 10px;
height: 150px;
padding: 10px;
border: solid 1px hsl(0deg 0% 100% / 5%);
transition: padding ease-in-out 0.4s;
position: relative;
}
.uploadBoxMain:hover {
padding: 5px;
}
.uploadBoxMainInside {
padding: 10px;
border: dashed 2px hsl(0deg 0% 100% / 5%);
border-radius: 8px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: hsl(0deg 0% 100% / 20%);
grid-gap: 10px;
cursor: pointer;
}
.inputLabelWrapperMain.inputLabelWrapperMainQR {
align-items: center;
padding: 25px;
background: rgb(0 0 0 / 10%);
border-radius: 10px;
border: solid 1px rgb(255 255 255 / 10%);
}

View File

@ -1,3 +1,4 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { SortBy, NSFWFilter, ModeratedFilter } from './modsFilter'
export interface BlogForm {
@ -20,9 +21,7 @@ export interface BlogDetails extends BlogForm {
tTags: string[]
}
export interface BlogEventSubmitForm extends Omit<BlogForm, 'nsfw'> {
nsfw: string
}
export interface BlogEventSubmitForm extends BlogForm {}
export interface BlogEventEditForm extends BlogEventSubmitForm {
dTag: string
@ -38,9 +37,11 @@ export interface BlogCardDetails extends BlogDetails {
export interface BlogPageLoaderResult {
blog: Partial<BlogDetails> | undefined
event: NDKEvent | undefined
latest: Partial<BlogDetails>[]
isAddedToNSFW: boolean
isBlocked: boolean
postWarning?: 'user' | 'admin'
}
export interface BlogsFilterOptions {

15
src/types/comments.ts Normal file
View File

@ -0,0 +1,15 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
export interface CommentsLoaderResult {
event: NDKEvent
}
export enum SortByEnum {
Latest = 'Latest',
Oldest = 'Oldest'
}
export enum AuthorFilterEnum {
All_Comments = 'All Comments',
Creator_Comments = 'Creator Comments'
}

70
src/types/errors.ts Normal file
View File

@ -0,0 +1,70 @@
import { toast } from 'react-toastify'
import { log, LogType } from 'utils'
export type Jsonable =
| string
| number
| boolean
| null
| undefined
| readonly Jsonable[]
| { readonly [key: string]: Jsonable }
| { toJSON(): Jsonable }
/**
* Handle errors
* Wraps the errors without message property and stringify to a message so we can use it later
* @param error
* @returns
*/
export function handleError(error: unknown): Error {
if (error instanceof Error) return error
// No message error, wrap it and stringify
let stringified = 'Unable to stringify the thrown value'
try {
stringified = JSON.stringify(error)
} catch (error) {
console.error(stringified, error)
}
return new Error(`Stringified Error: ${stringified}`)
}
export class BaseError extends Error {
public readonly context?: Jsonable
constructor(
message: string,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause: cause })
this.name = this.constructor.name
this.context = context
Object.setPrototypeOf(this, BaseError.prototype)
}
}
export function errorFeedback(error: unknown) {
if (error instanceof Error) {
toast.error(error.message)
log(true, LogType.Error, error)
} else {
toast.error('Something went wrong.')
log(true, LogType.Error, error)
}
}
export class TimeoutError extends Error {
constructor(timeoutMs?: number) {
let message = 'Time elapsed.'
if (timeoutMs) {
message += `\n* ${timeoutMs}ms`
}
super(message)
this.name = this.constructor.name
Object.setPrototypeOf(this, TimeoutError.prototype)
}
}

View File

@ -6,3 +6,5 @@ export * from './zap'
export * from './blog'
export * from './category'
export * from './popup'
export * from './errors'
export * from './comments'

View File

@ -1,4 +1,4 @@
import { Event } from 'nostr-tools'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { BlogDetails } from 'types'
export enum CommentEventStatus {
@ -7,7 +7,8 @@ export enum CommentEventStatus {
Failed = 'Failed to publish comment.'
}
export interface CommentEvent extends Event {
export interface CommentEvent {
event: NDKEvent
status?: CommentEventStatus
}
@ -37,15 +38,38 @@ export interface ModFormState {
lTags: string[]
downloadUrls: DownloadUrl[]
published_at: number
// Permissions and details
otherAssets?: boolean
uploadPermission?: boolean
modPermission?: boolean
convPermission?: boolean
assetUsePermission?: boolean
assetUseComPermission?: boolean
publisherNotes?: string
extraCredits?: string
}
// Permissions
export type ModPermissions = Pick<
ModFormState,
| 'otherAssets'
| 'uploadPermission'
| 'modPermission'
| 'convPermission'
| 'assetUsePermission'
| 'assetUseComPermission'
>
export interface DownloadUrl {
url: string
title?: string
hash: string
signatureKey: string
malwareScanLink: string
modVersion: string
customNote: string
mediaUrl?: string
}
export interface ModDetails extends Omit<ModFormState, 'tags'> {
@ -62,12 +86,25 @@ export interface MuteLists {
export interface ModPageLoaderResult {
mod: ModDetails | undefined
event: NDKEvent | undefined
latest: Partial<BlogDetails>[]
isAddedToNSFW: boolean
isBlocked: boolean
isRepost: boolean
postWarning?: 'user' | 'admin'
}
export type SubmitModActionResult =
| { type: 'validation'; error: FormErrors }
| {
type: 'timeout'
data: {
dTag: string
aTag: string
published_at: number
}
}
export interface FormErrors {
game?: string
title?: string
@ -81,3 +118,61 @@ export interface FormErrors {
author?: string
originalAuthor?: string
}
type ModPermissionsKeys = keyof ModPermissions
type ModPermissionsOpts = 'true' | 'false'
type ModPermissionsKeysOpts = `${ModPermissionsKeys}_${ModPermissionsOpts}`
type ModPermissionsDesc = {
[k in ModPermissionsKeysOpts]: string
}
type ModPermissionsConf = {
[k in ModPermissionsKeys]: {
header: string
default: boolean
}
}
export const MODPERMISSIONS_CONF: ModPermissionsConf = {
otherAssets: {
header: `Others' Assets`,
default: true
},
uploadPermission: {
header: `Redistribution Permission`,
default: true
},
modPermission: {
header: `Modification Permission`,
default: true
},
convPermission: {
header: `Conversion Permission`,
default: true
},
assetUsePermission: {
header: `Asset Use Permission`,
default: true
},
assetUseComPermission: {
header: `Asset Use Permission for Commercial Mods`,
default: false
}
}
export const MODPERMISSIONS_DESC: ModPermissionsDesc = {
otherAssets_true: `All assets in this file are either owned by the publisher or sourced from free-to-use modder's resources.`,
otherAssets_false: `Not all assets in this file are owned by the publisher or sourced from free-to-use modder's resources; some assets may be.`,
uploadPermission_true: `You are allowed to upload this file to other sites, but you must give credit to me as the creator (unless indicated otherwise).`,
uploadPermission_false: `You are not allowed to upload this file to other sites without explicit permission.`,
modPermission_true: `You may modify my files and release bug fixes or enhancements, provided that you credit me as the original creator.`,
modPermission_false: `You are not allowed to convert this file for use with other games without explicit permission.`,
convPermission_true: `You are permitted to convert this file for use with other games, as long as you credit me as the creator.`,
convPermission_false: `You are not permitted to convert this file for use with other games without explicit permission.`,
assetUsePermission_true: `You may use the assets in this file without needing permission, provided you give me credit.`,
assetUsePermission_false: `You must obtain explicit permission to use the assets in this file.`,
assetUseComPermission_true: `You are allowed to use assets from this file in mods or files that are sold for money on Steam Workshop or other platforms.`,
assetUseComPermission_false: `You are prohibited from using assets from this file in any mods or files that are sold for money on Steam Workshop or other platforms, unless given explicit permission.`
}

View File

@ -14,7 +14,8 @@ export enum NSFWFilter {
export enum ModeratedFilter {
Moderated = 'Moderated',
Unmoderated = 'Unmoderated',
Unmoderated_Fully = 'Unmoderated Fully'
Unmoderated_Fully = 'Unmoderated Fully',
Only_Blocked = 'Only Moderated'
}
export enum WOTFilterOptions {

View File

@ -1,5 +1,10 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { BlogCardDetails, BlogDetails } from 'types'
import {
BlogCardDetails,
BlogDetails,
BlogEventEditForm,
BlogEventSubmitForm
} from 'types'
import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr'
import { kinds, nip19 } from 'nostr-tools'
@ -50,3 +55,25 @@ export const extractBlogCardDetails = (
: undefined
}
}
export const initializeBlogForm = (
blog?: Partial<BlogDetails>
): BlogEventSubmitForm | BlogEventEditForm => ({
content: blog?.content || '',
image: blog?.image || '',
nsfw: blog?.nsfw || false,
summary: blog?.summary || '',
title: blog?.title || '',
tags: blog?.tTags?.join(', ') || '',
...(blog?.aTag && {
aTag: blog.aTag
}),
...(blog?.dTag && {
dTag: blog.dTag
}),
...(blog?.published_at && {
published_at: blog.published_at
})
})
export const BLOG_DRAFT_CACHE_KEY = 'draft-blog'

106
src/utils/comments.ts Normal file
View File

@ -0,0 +1,106 @@
import NDK, { NDKEvent, NDKNip07Signer } from '@nostr-dev-kit/ndk'
import { toast } from 'react-toastify'
import { CommentEvent, CommentEventStatus } from 'types'
import { log, LogType } from './utils'
export function handleCommentSubmit(
event: NDKEvent | undefined,
setCommentEvents: React.Dispatch<React.SetStateAction<CommentEvent[]>>,
setVisible: React.Dispatch<React.SetStateAction<CommentEvent[]>>,
ndk: NDK
) {
return async (content: string): Promise<boolean> => {
if (content === '') return false
// NDKEvent required
if (!event) return false
let id: string | undefined
try {
const reply = event.reply()
reply.content = content.trim()
setCommentEvents((prev) => {
const newCommentEvents = [
{
event: reply,
status: CommentEventStatus.Publishing
},
...prev
]
setVisible(newCommentEvents)
return newCommentEvents
})
if (!ndk.signer) {
ndk.signer = new NDKNip07Signer()
}
await reply.sign(ndk.signer)
id = reply.id
const relaySet = await reply.publish()
if (relaySet.size) {
setCommentEvents((prev) => {
const newCommentEvents = prev.map((ce) => {
if (ce.event.id === reply.id) {
return {
event: ce.event,
status: CommentEventStatus.Published
}
}
return ce
})
setVisible(newCommentEvents)
return newCommentEvents
})
// when an event is successfully published remove the status from it after 15 seconds
setTimeout(() => {
setCommentEvents((prev) => {
const newCommentEvents = prev.map((ce) => {
if (ce.event.id === reply.id) {
delete ce.status
}
return ce
})
setVisible(newCommentEvents)
return newCommentEvents
})
}, 15000)
return true
} else {
log(true, LogType.Error, 'Publishing reply failed.')
setCommentEvents((prev) => {
const newCommentEvents = prev.map((ce) => {
if (ce.event.id === reply.id) {
return {
event: ce.event,
status: CommentEventStatus.Failed
}
}
return ce
})
setVisible(newCommentEvents)
return newCommentEvents
})
return false
}
} catch (error) {
toast.error('An error occurred in publishing reply.')
log(true, LogType.Error, 'An error occurred in publishing reply.', error)
setCommentEvents((prev) => {
const newCommentEvents = prev.map((ce) => {
if (ce.event.id === id) {
return {
event: ce.event,
status: CommentEventStatus.Failed
}
}
return ce
})
setVisible(newCommentEvents)
return newCommentEvents
})
return false
}
}
}

View File

@ -9,3 +9,4 @@ export * from './consts'
export * from './blog'
export * from './curationSets'
export * from './category'
export * from './comments'

View File

@ -1,7 +1,8 @@
import _ from 'lodash'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Event } from 'nostr-tools'
import { ModDetails, ModFormState } from '../types'
import { getTagValue, getTagValues } from './nostr'
import { getTagValue, getTagValues, getFirstTagValue as _getFirstTagValue } from './nostr'
/**
* Extracts and normalizes mod data from an event.
@ -25,6 +26,14 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
return tagValue ? parseInt(tagValue[0], 10) : defaultValue
}
// Optional mod values
const otherAssets = _getFirstTagValue(event, 'otherAssets')
const uploadPermission = _getFirstTagValue(event, 'uploadPermission')
const modPermission = _getFirstTagValue(event, 'modPermission')
const convPermission = _getFirstTagValue(event, 'convPermission')
const assetUsePermission = _getFirstTagValue(event, 'assetUsePermission')
const assetUseComPermission = _getFirstTagValue(event, 'assetUseComPermission')
return {
id: event.id,
dTag: getFirstTagValue('d'),
@ -51,7 +60,15 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
),
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
JSON.parse(item)
)
),
otherAssets: otherAssets ? otherAssets === 'true' : undefined,
uploadPermission: uploadPermission ? uploadPermission === 'true' : undefined,
modPermission: modPermission ? modPermission === 'true' : undefined,
convPermission: convPermission ? convPermission === 'true' : undefined,
assetUsePermission: assetUsePermission ? assetUsePermission === 'true' : undefined,
assetUseComPermission: assetUseComPermission ? assetUseComPermission === 'true' : undefined,
publisherNotes: _getFirstTagValue(event, 'publisherNotes'),
extraCredits: _getFirstTagValue(event, 'extraCredits')
}
}
@ -131,16 +148,30 @@ export const initializeFormState = (
originalAuthor: existingModData?.originalAuthor || undefined,
screenshotsUrls: existingModData?.screenshotsUrls || [''],
tags: existingModData?.tags.join(',') || '',
lTags: existingModData?.lTags || [],
LTags: existingModData?.LTags || [],
downloadUrls: existingModData?.downloadUrls || [
{
url: '',
hash: '',
signatureKey: '',
malwareScanLink: '',
modVersion: '',
customNote: ''
}
]
lTags: existingModData ? _.clone(existingModData.lTags) : [],
LTags: existingModData ? _.clone(existingModData.lTags) : [],
downloadUrls: existingModData
? _.cloneDeep(existingModData.downloadUrls)
: [
{
title: '',
url: '',
hash: '',
signatureKey: '',
malwareScanLink: '',
modVersion: '',
customNote: '',
mediaUrl: ''
}
],
otherAssets: existingModData?.otherAssets ?? true,
uploadPermission: existingModData?.uploadPermission ?? true,
modPermission: existingModData?.modPermission ?? true,
convPermission:existingModData?.convPermission ?? true,
assetUsePermission:existingModData?.assetUsePermission ?? true,
assetUseComPermission:existingModData?.assetUseComPermission ?? false,
publisherNotes:existingModData?.publisherNotes || '',
extraCredits:existingModData?.extraCredits || ''
})
export const MOD_DRAFT_CACHE_KEY = 'draft-mod'

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