Compare commits

..

39 Commits

Author SHA1 Message Date
186cd4c0da revert edc01e028d
revert Merge pull request 'Relay operations refactored with NDK for publishing events (+ more. Wrapped up refactoring), pagination scroll up on click, body scroll disable/enable when popups appear/disappear, nsfw tag shown on mod cards if mod post is nsfw' (#92) from staging into master

Reviewed-on: #92
2024-10-21 14:48:11 +00:00
edc01e028d Merge pull request 'Relay operations refactored with NDK for publishing events (+ more. Wrapped up refactoring), pagination scroll up on click, body scroll disable/enable when popups appear/disappear, nsfw tag shown on mod cards if mod post is nsfw' (#92) from staging into master
Reviewed-on: #92
2024-10-21 14:17:02 +00:00
4cebcc8a25 Merge pull request 'added more games, adjusted/added text in mod submission page' (#86) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #86
2024-10-19 07:51:02 +00:00
7e8d4c9498 Merge pull request 'GitHub pages flow fix, image errors and field duplication and field unfocus in mod submission fix' (#83) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #83
2024-10-18 10:08:43 +00:00
freakoverse
3f9e39a92a Merge pull request 'Relay operation refactored with NDK for fetching events' (#79) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #79
2024-10-14 15:04:53 +00:00
freakoverse
7aef0b3456 Merge pull request 'added a guide link in the registration pop-up' (#74) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
Reviewed-on: #74
2024-10-12 19:01:41 +00:00
freakoverse
045d7a96ea Merge pull request 'adjusted footer text, reorganized games' (#71) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
Reviewed-on: #71
2024-10-11 11:47:19 +00:00
freakoverse
f159e4692a Merge pull request 'Upload files to "public/assets/games"' (#70) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #70
2024-10-07 17:33:42 +00:00
freakoverse
25fcdce7b0 Merge pull request 'added NSFW filter and source filter to a game's page to see their mods, adjusted landing page content' (#69) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #69
2024-10-07 12:05:03 +00:00
freakoverse
1928c0e4e9 Merge pull request 'updated games and adjusted text for submit mod page' (#68) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 55s
Reviewed-on: #68
2024-10-06 23:00:54 +00:00
freakoverse
1df471b0ff Merge pull request 'reorganized game files' (#66) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #66
2024-10-04 11:49:20 +00:00
freakoverse
7840c32d2b Merge pull request 'feat: appyly nsfw filter on latest mods section in home page' (#65) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #65
2024-10-03 19:51:03 +00:00
freakoverse
c1d226b4d4 Merge pull request 'Update src/components/Zap.tsx' (#62) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #62
2024-10-01 06:57:49 +00:00
freakoverse
fb69c0f97d Merge pull request 'implemented zap split' (#60) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #60
2024-10-01 06:18:43 +00:00
freakoverse
5d791413ff Merge pull request 'added download url in authentication details, fixed blocked mods showing on landing page, fixed editing mod data not showing' (#59) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #59
2024-09-30 11:56:27 +00:00
freakoverse
e46c022d3d Merge pull request 'Upload files to "public/assets/games"' (#55) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #55
2024-09-29 06:12:42 +00:00
freakoverse
96f13d88f0 Merge pull request 'added another button/link for nostr connect firefox extension' (#54) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 42s
Reviewed-on: #54
2024-09-29 05:46:25 +00:00
freakoverse
c6e8c1a91f Merge pull request 'Upload files to "public/assets/games"' (#53) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #53
2024-09-29 05:39:44 +00:00
freakoverse
d306e7d1b4 Merge pull request 'changed game card's title to show 2 lines instead of 1' (#50) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #50
2024-09-27 10:57:25 +00:00
freakoverse
d0425e31cd Merge pull request 'relay management (settings), profile box display fix, /games mod fetch filter by current site source, game in mod post to redirect to game search for that game' (#49) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #49
2024-09-25 16:22:46 +00:00
freakoverse
397ec48444 Merge pull request 'small style and text changes' (#47) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #47
2024-09-24 18:22:47 +00:00
freakoverse
9bdbb6d24a Merge pull request 'profile edit' (#46) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #46
2024-09-24 17:36:55 +00:00
freakoverse
53cc4f0c79 Merge pull request 'display reactions, comments, and zap data in mod cards' (#44) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #44
2024-09-23 16:29:01 +00:00
freakoverse
c4b0d4fce5 Merge pull request 'showcase games in /games of ones with mods fetched from the latest 100 mod posts, added new games file' (#41) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #41
2024-09-23 12:35:46 +00:00
freakoverse
55f6960cc5 Merge pull request 'search redirections, games in landing image fetch, updated filters behavior in the search page' (#40) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #40
2024-09-23 10:53:17 +00:00
freakoverse
d01521a5f0 Merge pull request 'Multi-file games lists, clicking a game leads to mod search for that game, search redirects, pagination optimizations' (#38) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #38
2024-09-18 19:30:26 +00:00
freakoverse
9cb3d2fb63 Merge pull request 'social nav + search system' (#37) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #37
2024-09-18 10:08:03 +00:00
freakoverse
4233ad4ce7 Merge pull request 'multiple zapping issues resolved for confirming and showing active states of zaps' (#36) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #36
2024-09-11 18:02:41 +00:00
freakoverse
1ff8d9ed7b Merge pull request 'Comment system on mods implemented' (#35) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #35
2024-09-11 13:15:44 +00:00
freakoverse
ce994b47c9 Merge pull request 'link change' (#34) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #34
2024-09-05 13:12:30 +00:00
freakoverse
42a8eef755 Merge pull request 'Game name now visible on game mod cards and sliders. Mod post reactions now functional. Zap refactored.' (#33) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #33
2024-09-05 10:39:45 +00:00
freakoverse
8227de2d80 Merge pull request 'fix: latest mods on landing. change: mods-inner url to mod' (#32) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #32
2024-09-03 10:32:20 +00:00
freakoverse
a23b0a2304 Merge pull request 'new: profile box. fix: landing page latest mods' (#31) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 45s
Reviewed-on: #31
2024-09-03 10:06:14 +00:00
freakoverse
25fd2ca6f7 Merge pull request 'slider autoplay fix' (#30) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #30
2024-09-02 17:40:57 +00:00
freakoverse
0e08d09717 Merge pull request 'Can now modify landing page. Fallback images for games and mods. Cached events.' (#29) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 44s
Reviewed-on: #29
2024-09-02 13:28:17 +00:00
freakoverse
fc713e9ee4 Merge pull request 'feat: add image gallery' (#28) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 47s
Reviewed-on: #28
2024-08-29 18:15:18 +00:00
freakoverse
cd150142f8 Merge pull request 'all filters fully functional. reporting system added. multiple fixes.' (#27) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #27
2024-08-29 17:40:02 +00:00
freakoverse
e06a8d6e96 Merge pull request 'new text editor added (tiptap)' (#24) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 46s
Reviewed-on: #24
2024-08-27 12:59:11 +00:00
s
54ec62df1a Merge pull request 'staging' (#23) from staging into master
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
Reviewed-on: #23
2024-08-26 13:57:06 +00:00
96 changed files with 52392 additions and 55348 deletions

View File

@ -11,7 +11,4 @@ VITE_REPORTING_NPUB= <npub1...>
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# A comma separated list of npubs, this list is used to fetch just the posts from the admin
VITE_BLOG_NPUBS= <A comma separated list of npubs>
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png

View File

@ -1,4 +1,4 @@
name: Release to Production
name: Release to Staging
on:
push:
branches:
@ -27,8 +27,6 @@ jobs:
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Create Build

View File

@ -27,8 +27,6 @@ jobs:
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Create Build

View File

@ -35,8 +35,6 @@ jobs:
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env
- name: Build
run: npm run build

View File

@ -9,47 +9,6 @@
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
<title>DEG Mods - Liberating Game Mods</title>
<!-- Start Hash Router Backwards Compatibility -->
<script type="text/javascript">
;(function (l) {
if (l.hash.startsWith('#/')) {
l.href = l.href.replace('#/', '')
}
})(window.location)
</script>
<!-- End Hash Router Backwards Compatibility -->
<!-- Start Single Page Apps for GitHub Pages -->
<script type="text/javascript">
// Single Page Apps for GitHub Pages
// MIT License
// https://github.com/rafgraph/spa-github-pages
// This script checks to see if a redirect is present in the query string,
// converts it back into the correct url and adds it to the
// browser's history using window.history.replaceState(...),
// which won't cause the browser to attempt to load the new url.
// When the single page app is loaded further down in this file,
// the correct url will be waiting in the browser's history for
// the single page app to route accordingly.
;(function (l) {
if (l.search[1] === '/') {
var decoded = l.search
.slice(1)
.split('&')
.map(function (s) {
return s.replace(/~and~/g, '&')
})
.join('?')
window.history.replaceState(
null,
null,
l.pathname.slice(0, -1) + decoded + l.hash
)
}
})(window.location)
</script>
<!-- End Single Page Apps for GitHub Pages -->
</head>
<body>
<div id="root"></div>

400
package-lock.json generated
View File

@ -1,22 +1,21 @@
{
"name": "degmods.com",
"version": "0.0.0-alpha-1",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "degmods.com",
"version": "0.0.0-alpha-1",
"version": "0.0.0",
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-link": "2.9.1",
"@tiptap/react": "2.9.1",
"@tiptap/starter-kit": "2.9.1",
"@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6",
"@tiptap/react": "2.6.6",
"@tiptap/starter-kit": "2.6.6",
"@types/react-helmet": "^6.1.11",
"axios": "1.7.3",
"bech32": "2.0.0",
@ -27,7 +26,6 @@
"file-saver": "2.0.5",
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"marked": "^14.1.3",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
"papaparse": "5.4.1",
@ -41,7 +39,6 @@
"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"
},
@ -54,7 +51,6 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
"@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
@ -1042,12 +1038,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"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",
@ -1162,7 +1152,6 @@
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@ -1192,10 +1181,9 @@
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz",
"integrity": "sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ=="
},
"node_modules/@remix-run/router": {
"version": "1.17.1",
@ -1491,49 +1479,45 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.9.1.tgz",
"integrity": "sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz",
"integrity": "sha512-VO5qTsjt6rwworkuo0s5AqYMfDA0ZwiTiH6FHKFSu2G/6sS7HKcc/LjPq+5Legzps4QYdBDl3W28wGsGuS1GdQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.9.1.tgz",
"integrity": "sha512-Y0jZxc/pdkvcsftmEZFyG+73um8xrx6/DMfgUcNg3JAM63CISedNcr+OEI11L0oFk1KFT7/aQ9996GM6Kubdqg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.6.6.tgz",
"integrity": "sha512-hAdsNlMfzzxld154hJqPqtWqO5i4/7HoDfuxmyqBxdMJ+e2UMaIGBGwoLRXG0V9UoRwJusjqlpyD7pIorxNlgA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.9.1.tgz",
"integrity": "sha512-e2P1zGpnnt4+TyxTC5pX/lPxPasZcuHCYXY0iwQ3bf8qRQQEjDfj3X7EI+cXqILtnhOiviEOcYmeu5op2WhQDg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.6.6.tgz",
"integrity": "sha512-CD6gBhdQtCoqYSmx8oAV8gvKtVOGZSyyvuNYo7by9eZ56DqLYnd7kbUj0RH7o9Ymf/iJTOUJ6XcvrsWwo4lubg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.9.1.tgz",
"integrity": "sha512-DWUF6NG08/bZDWw0jCeotSTvpkyqZTi4meJPomG9Wzs/Ol7mEwlNCsCViD999g0+IjyXFatBk4DfUq1YDDu++Q==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.6.6.tgz",
"integrity": "sha512-IkfmlZq67aaegym5sBddBc/xXWCArxn5WJEl1oxKEayjQhybKSaqI7tk0lOx/x7fa5Ml1WlGpCFh+KKXbQTG0g==",
"dependencies": {
"tippy.js": "^6.3.7"
},
@ -1542,82 +1526,76 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.9.1.tgz",
"integrity": "sha512-0hizL/0j9PragJObjAWUVSuGhN1jKjCFnhLQVRxtx4HutcvS/lhoWMvFg6ZF8xqWgIa06n6A7MaknQkqhTdhKA==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.6.6.tgz",
"integrity": "sha512-WEKxbVSYuvmX2wkHWP8HXk5nzA7stYwtdaubwWH/R17kGI3IGScJuMQ9sEN82uzJU8bfgL9yCbH2bY8Fj/Q4Ow==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.9.1.tgz",
"integrity": "sha512-WQqcVGe7i/E+yO3wz5XQteU1ETNZ00euUEl4ylVVmH2NM4Dh0KDjEhbhHlCM0iCfLUo7jhjC7dmS+hMdPUb+Tg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.6.6.tgz",
"integrity": "sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.9.1.tgz",
"integrity": "sha512-A/50wPWDqEUUUPhrwRKILP5gXMO5UlQ0F6uBRGYB9CEVOREam9yIgvONOnZVJtszHqOayjIVMXbH/JMBeq11/g==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.6.6.tgz",
"integrity": "sha512-1YLp/zHMHSkE2xzht8nPR6T4sQJJ3ket798czxWuQEbetFv/l0U/mpiPpYSLObj6oTAoqYZ0kWXZj5eQSpPB8Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.9.1.tgz",
"integrity": "sha512-1a+HCoDPnBttjqExfYLwfABq8MYdiowhy/wp8eCxVb6KGFEENO53KapstISvPzqH7eOi+qRjBB1KtVYb/ZXicg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.6.6.tgz",
"integrity": "sha512-6qlH5VWzLHHRVeeciRC6C4ZHpMsAGPNG16EF53z0GeMSaaFD/zU3B239QlmqXmLsAl8bpf8Bn93N0t2ABUvScw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.9.1.tgz",
"integrity": "sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.6.6.tgz",
"integrity": "sha512-O6CeKriA9uyHsg7Ui4z5ZjEWXQxrIL+1zDekffW0wenGC3G4LUsCzAiFS4LSrR9a3u7tnwqGApW10rdkmCGF4w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.9.1.tgz",
"integrity": "sha512-MxZ7acNNsoNaKpetxfwi3Z11Bgrh0T2EJlCV77v9N1vWK38+st3H1WJanmLbPNtc2ocvhHJrz+DjDz3CWxQ9rQ==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.6.6.tgz",
"integrity": "sha512-lPkESOfAUxgmXRiNqUU23WSyja5FUfSWjsW4hqe+BKNjsUt1OuFMEtYJtNc+MCGhhtPfFvM3Jg6g9jd6g5XsLQ==",
"dependencies": {
"tippy.js": "^6.3.7"
},
@ -1626,109 +1604,89 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.9.1.tgz",
"integrity": "sha512-jsRBmX01vr+5H02GljiHMo0n5H1vzoMLmFarxe0Yq2d2l9G/WV2VWX2XnGliqZAYWd1bI0phs7uLQIN3mxGQTw==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.6.6.tgz",
"integrity": "sha512-O2lQ2t0X0Vsbn3yLWxFFHrXY6C2N9Y6ZF/M7LWzpcDTUZeWuhoNkFE/1yOM0h6ZX1DO2A9hNIrKpi5Ny8yx+QA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.9.1.tgz",
"integrity": "sha512-fCuaOD/b7nDjm47PZ58oanq7y4ccS2wjPh42Qm0B0yipu/1fmC8eS1SmaXmk28F89BLtuL6uOCtR1spe+lZtlQ==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.6.6.tgz",
"integrity": "sha512-bsUuyYBrMDEiudx1dOQSr9MzKv13m0xHWrOK+DYxuIDYJb5g+c9un5cK7Js+et/HEYYSPOoH/iTW6h+4I5YeUg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.9.1.tgz",
"integrity": "sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.6.6.tgz",
"integrity": "sha512-bgx9vptVFi5yFkIw1OI53J7+xJ71Or3SOe/Q8eSpZv53DlaKpL/TzKw8Z54t1PrI2rJ6H9vrLtkvixJvBZH1Ug==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.9.1.tgz",
"integrity": "sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.6.6.tgz",
"integrity": "sha512-tPTzAmPGqMX5Bd5H8lzRpmsaMvB9DvI5Dy2za/VQuFtxgXmDiFVgHRkRXIuluSkPTuANu84XBOQ0cBijqY8x4w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.9.1.tgz",
"integrity": "sha512-ydUhABeaBI1CoJp+/BBqPhXINfesp1qMNL/jiDcMsB66fsD4nOyphpAJT7FaRFZFtQVF06+nttBtFZVkITQVqg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.6.6.tgz",
"integrity": "sha512-cFEfv7euDpuLSe8exY8buwxkreKBAZY9Hn3EetKhPcLQo+ut5Y24chZTxFyf9b+Y0wz3UhOhLTZSz7fTobLqBA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.9.1.tgz",
"integrity": "sha512-aGqJnsuS8oagIhsx7wetm8jw4NEDsOV0OSx4FQ4VPlUqWlnzK0N+erFKKJmXTdAxL8PGzoPSlITFH63MV3eV3Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.9.1.tgz",
"integrity": "sha512-VkNA6Vz96+/+7uBlsgM7bDXXx4b62T1fDam/3UKifA72aD/fZckeWrbT7KrtdUbzuIniJSbA0lpTs5FY29+86Q==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.6.6.tgz",
"integrity": "sha512-t7ZPsXqa8nJZZ/6D0rQyZ/KsvzLaSihC6hBTjUQ77CeDGV9PhDWjIcBW4OrvwraJDBd12ETBeQ2CkULJOgH+lQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.9.1.tgz",
"integrity": "sha512-yG+e3e8cCCN9dZjX4ttEe3e2xhh58ryi3REJV4MdiEkOT9QF75Bl5pUbMIS4tQ8HkOr04QBFMHKM12kbSxg1BA==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.6.6.tgz",
"integrity": "sha512-NJSR5Yf/dI3do0+Mr6e6nkbxRQcqbL7NOPxo5Xw8VaKs2Oe8PX+c7hyqN3GZgn6uEbZdbVi1xjAniUokouwpFg==",
"dependencies": {
"linkifyjs": "^4.1.0"
},
@ -1737,97 +1695,78 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.9.1.tgz",
"integrity": "sha512-6O4NtYNR5N2Txi4AC0/4xMRJq9xd4+7ShxCZCDVL0WDVX37IhaqMO7LGQtA6MVlYyNaX4W1swfdJaqrJJ5HIUw==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.6.6.tgz",
"integrity": "sha512-k+oEzZu2cgVKqPqOP1HzASOKLpTEV9m7mRVPAbuaaX8mSyvIgD6f+JUx9PvgYv//D918wk98LMoRBFX53tDJ4w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.9.1.tgz",
"integrity": "sha512-6J9jtv1XP8dW7/JNSH/K4yiOABc92tBJtgCsgP8Ep4+fjfjdj4HbjS1oSPWpgItucF2Fp/VF8qg55HXhjxHjTw==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.6.6.tgz",
"integrity": "sha512-AJwyfLXIi7iUGnK5twJbwdVVpQyh7fU6OK75h1AwDztzsOcoPcxtffDlZvUOd4ZtwuyhkzYqVkeI0f+abTWZTw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.9.1.tgz",
"integrity": "sha512-JOmT0xd4gd3lIhLwrsjw8lV+ZFROKZdIxLi0Ia05XSu4RLrrvWj0zdKMSB+V87xOWfSB3Epo95zAvnPox5Q16A==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.6.6.tgz",
"integrity": "sha512-fD/onCr16UQWx+/xEmuFC2MccZZ7J5u4YaENh8LMnAnBXf78iwU7CAcmuc9rfAEO3qiLoYGXgLKiHlh2ZfD4wA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.9.1.tgz",
"integrity": "sha512-V5aEXdML+YojlPhastcu7w4biDPwmzy/fWq0T2qjfu5Te/THcqDmGYVBKESBm5x6nBy5OLkanw2O+KHu2quDdg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.6.6.tgz",
"integrity": "sha512-Ze8KhGk+wzSJSJRl5fbhTI6AvPu2LmcHYeO3pMEH8u4gV5WTXfmKJVStEIAzkoqvwEQVWzXvy8nDgsFQHiojPg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.9.1.tgz",
"integrity": "sha512-3wo9uCrkLVLQFgbw2eFU37QAa1jq1/7oExa+FF/DVxdtHRS9E2rnUZ8s2hat/IWzvPUHXMwo3Zg2XfhoamQpCA==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.6.6.tgz",
"integrity": "sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.9.1.tgz",
"integrity": "sha512-LAxc0SeeiPiAVBwksczeA7BJSZb6WtVpYhy5Esvy9K0mK5kttB4KxtnXWeQzMIJZQbza65yftGKfQlexf/Y7yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
"@tiptap/core": "^2.6.6"
}
},
"node_modules/@tiptap/pm": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.9.1.tgz",
"integrity": "sha512-mvV86fr7kEuDYEApQ2uMPCKL2uagUE0BsXiyyz3KOkY1zifyVm1fzdkscb24Qy1GmLzWAIIihA+3UHNRgYdOlQ==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.6.6.tgz",
"integrity": "sha512-56FGLPn3fwwUlIbLs+BO21bYfyqP9fKyZQbQyY0zWwA/AG2kOwoXaRn7FOVbjP6CylyWpFJnpRRmgn694QKHEg==",
"dependencies": {
"prosemirror-changeset": "^2.2.1",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0",
"prosemirror-commands": "^1.5.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@ -1835,14 +1774,14 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.22.3",
"prosemirror-model": "^1.22.2",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.0",
"prosemirror-view": "^1.34.3"
"prosemirror-trailing-node": "^2.0.9",
"prosemirror-transform": "^1.9.0",
"prosemirror-view": "^1.33.9"
},
"funding": {
"type": "github",
@ -1850,15 +1789,13 @@
}
},
"node_modules/@tiptap/react": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.9.1.tgz",
"integrity": "sha512-LQJ34ZPfXtJF36SZdcn4Fiwsl2WxZ9YRJI87OLnsjJ45O+gV/PfBzz/4ap+LF8LOS0AbbGhTTjBOelPoNm+aYA==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.6.6.tgz",
"integrity": "sha512-AUmdb/J1O/vCO2b8LL68ctcZr9a3931BwX4fUUZ1kCrCA5lTj2xz0rjeAtpxEdzLnR+Z7q96vB7vf7bPYOUAew==",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.9.1",
"@tiptap/extension-floating-menu": "^2.9.1",
"@tiptap/extension-bubble-menu": "^2.6.6",
"@tiptap/extension-floating-menu": "^2.6.6",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1.2.2"
},
"funding": {
@ -1866,8 +1803,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"@tiptap/core": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
@ -1878,32 +1815,30 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@tiptap/starter-kit": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.9.1.tgz",
"integrity": "sha512-nsw6UF/7wDpPfHRhtGOwkj1ipIEiWZS1VGw+c14K61vM1CNj0uQ4jogbHwHZqN1dlL5Hh+FCqUHDPxG6ECbijg==",
"license": "MIT",
"version": "2.6.6",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.6.6.tgz",
"integrity": "sha512-zb9xIg3WjG9AsJoyWrfqx5SL9WH7/HTdkB79jFpWtOF/Kaigo7fHFmhs2FsXtJMJlcdMTO2xeRuCYHt5ozXlhg==",
"dependencies": {
"@tiptap/core": "^2.9.1",
"@tiptap/extension-blockquote": "^2.9.1",
"@tiptap/extension-bold": "^2.9.1",
"@tiptap/extension-bullet-list": "^2.9.1",
"@tiptap/extension-code": "^2.9.1",
"@tiptap/extension-code-block": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
"@tiptap/extension-dropcursor": "^2.9.1",
"@tiptap/extension-gapcursor": "^2.9.1",
"@tiptap/extension-hard-break": "^2.9.1",
"@tiptap/extension-heading": "^2.9.1",
"@tiptap/extension-history": "^2.9.1",
"@tiptap/extension-horizontal-rule": "^2.9.1",
"@tiptap/extension-italic": "^2.9.1",
"@tiptap/extension-list-item": "^2.9.1",
"@tiptap/extension-ordered-list": "^2.9.1",
"@tiptap/extension-paragraph": "^2.9.1",
"@tiptap/extension-strike": "^2.9.1",
"@tiptap/extension-text": "^2.9.1",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/pm": "^2.9.1"
"@tiptap/core": "^2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bold": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-horizontal-rule": "^2.6.6",
"@tiptap/extension-italic": "^2.6.6",
"@tiptap/extension-list-item": "^2.6.6",
"@tiptap/extension-ordered-list": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-strike": "^2.6.6",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/pm": "^2.6.6"
},
"funding": {
"type": "github",
@ -2097,13 +2032,6 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
},
"node_modules/@types/turndown": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@ -3265,7 +3193,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-glob": {
"version": "3.3.2",
@ -3918,18 +3847,6 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/marked": {
"version": "14.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz",
"integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@ -4587,12 +4504,11 @@
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-2.0.9.tgz",
"integrity": "sha512-YvyIn3/UaLFlFKrlJB6cObvUhmwFNZVhy1Q8OpW/avoTbD/Y7H5EcjK4AZFKhmuS6/N6WkGgt7gWtBWDnmFvHg==",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"@remirror/core-constants": "^2.0.2",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
@ -4605,7 +4521,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -4622,10 +4537,9 @@
}
},
"node_modules/prosemirror-view": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.35.0.tgz",
"integrity": "sha512-Umtbh22fmUlpZpRTiOVXA0PpdRZeYEeXQsLp51VfnMhjkJrqJ0n8APinIZrRAD5Jr3UxH8FnOaUqRylSuMsqHA==",
"license": "MIT",
"version": "1.34.1",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.1.tgz",
"integrity": "sha512-KS2xmqrAM09h3SLu1S2pNO/ZoIP38qkTJ6KFd7+BeSfmX/ek0n5yOfGuiTZjFNTC8GOsEIUa1tHxt+2FMu3yWQ==",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -5114,7 +5028,6 @@
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
@ -5236,15 +5149,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",

View File

@ -1,7 +1,7 @@
{
"name": "degmods.com",
"private": true,
"version": "0.0.0-alpha-1",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -14,11 +14,10 @@
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-link": "2.9.1",
"@tiptap/react": "2.9.1",
"@tiptap/starter-kit": "2.9.1",
"@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6",
"@tiptap/react": "2.6.6",
"@tiptap/starter-kit": "2.6.6",
"@types/react-helmet": "^6.1.11",
"axios": "1.7.3",
"bech32": "2.0.0",
@ -29,7 +28,6 @@
"file-saver": "2.0.5",
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"marked": "^14.1.3",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
"papaparse": "5.4.1",
@ -43,7 +41,6 @@
"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"
},
@ -56,7 +53,6 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
"@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",

View File

@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Single Page Apps for GitHub Pages</title>
<script type="text/javascript">
// Single Page Apps for GitHub Pages
// MIT License
// https://github.com/rafgraph/spa-github-pages
// This script takes the current url and converts the path and query
// string into just a query string, and then redirects the browser
// to the new url with only a query string and hash fragment,
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
// Note: this 404.html file must be at least 512 bytes for it to work
// with Internet Explorer (it is currently > 512 bytes)
// If you're creating a Project Pages site and NOT using a custom domain,
// then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
// This way the code will only replace the route part of the path, and not
// the real directory in which the app resides, for example:
// https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
// https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
// Otherwise, leave pathSegmentsToKeep as 0.
var pathSegmentsToKeep = 0
var l = window.location
l.replace(
l.protocol +
'//' +
l.hostname +
(l.port ? ':' + l.port : '') +
l.pathname
.split('/')
.slice(0, 1 + pathSegmentsToKeep)
.join('/') +
'/?/' +
l.pathname
.slice(1)
.split('/')
.slice(pathSegmentsToKeep)
.join('/')
.replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash
)
</script>
</head>
<body></body>
</html>

View File

@ -1,12 +1,10 @@
import { RouterProvider } from 'react-router-dom'
import { Route, Routes } from 'react-router-dom'
import { Layout } from './layout'
import { routes } from './routes'
import { useEffect } from 'react'
import { routerWithNdkContext } from 'routes'
import { useNDKContext } from 'hooks'
import './styles/styles.css'
function App() {
const ndkContext = useNDKContext()
useEffect(() => {
// Find the element with id 'root'
const rootElement = document.getElementById('root')
@ -24,7 +22,19 @@ function App() {
}
}, [])
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
return (
<Routes>
<Route element={<Layout />}>
{routes.map((route, index) => (
<Route
key={route.path + index}
path={route.path}
element={route.element}
/>
))}
</Route>
</Routes>
)
}
export default App

File diff suppressed because it is too large Load Diff

View File

@ -38405,7 +38405,7 @@ Trap Legend Theme Song,,
Gemini Strategy Origin,,
Brain Marmelade,,
Forsaken Flesh,,
"Warhammer 40,000: Darktide",,https://image.nostr.build/0c405bdb168c05f21f54b3bda39852eb2f9fa851068367eccd69f2bc0526a600.jpg
"Warhammer 40,000: Darktide",,
Cleo - a pirate's tale,,
Teenage Blob: Paperperson - The First Single,,
Necronator: Dead Wrong - Special Commander Skin,,

Can't render this file because it is too large.

View File

@ -45067,7 +45067,7 @@ Rooftop Postgirl Demo,,
Mad Experiments 2: Premium Pack,,
Arise from Shadows Demo,,
Hentai Heaven's Slutty Salvation,,
Fields of Mistria,,https://image.nostr.build/deb8fb380cdf2f42750f115141d762de791bf49e0af97ec733390360a1a5ddbf.jpg
Fields of Mistria,,
Maskonauts: Chat'Attack Soundtrack,,
Nameless - The Departed Cycle,,
Grave-Queen,,

Can't render this file because it is too large.

View File

@ -1,2 +1,2 @@
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
Game Name,16 by 9 image,Boxart image
Marvel's Spider-Man 2,,https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp
1 Game Name 16 by 9 image Boxart image
2 Marvel's Spider-Man 2 https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 277 KiB

View File

@ -1,33 +1,37 @@
import { Link } from 'react-router-dom'
import { BlogCardDetails } from 'types'
import { getBlogPageRoute } from 'routes'
import '../styles/cardBlogs.css'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
type BlogCardProps = Partial<BlogCardDetails>
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
if (!naddr) return null
type BlogCardProps = {
backgroundLink: string
}
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
return (
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
<div
className='cardBlogMain'
style={{
background: `url("${
image ? image : placeholder
}") center / cover no-repeat`
background: `url("${backgroundLink}") center / cover no-repeat`
}}
>
<div className='cardBlogMainInside'>
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
{nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
<p>NSFW</p>
</div>
)}
<div
className='cardBlogMainInside'
>
<h3
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
WebkitLineClamp: 2,
fontSize: '20px',
lineHeight: 1.5,
color: 'rgba(255, 255, 255, 0.75)',
textShadow: '0 0 8px rgba(0, 0, 0, 0.25)'
}}
>
This is a blog title, the best blog title in the world!
</h3>
</div>
</div>
</Link>
</div>{' '}
</a>
)
}

View File

@ -1,5 +1,4 @@
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useEffect } from 'react'
@ -90,27 +89,13 @@ interface CheckboxFieldProps {
name: string
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type?: 'default' | 'stylized'
}
export const CheckboxField = React.memo(
({
label,
name,
isChecked,
handleChange,
type = 'default'
}: CheckboxFieldProps) => (
<div
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
}`}
>
<label htmlFor={name} className='form-label labelMain'>
{label}
</label>
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label className='form-label labelMain'>{label}</label>
<input
id={name}
type='checkbox'
className='CheckboxMain'
name={name}
@ -128,15 +113,7 @@ type RichTextEditorProps = {
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
const editor = useEditor({
extensions: [
StarterKit,
Link,
Image.configure({
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
extensions: [StarterKit, Link],
onUpdate: ({ editor }) => {
// Update the state when the editor content changes
updateContent(editor.getHTML())
@ -167,7 +144,7 @@ type MenuBarProps = {
editor: Editor
}
export const MenuBar = ({ editor }: MenuBarProps) => {
const MenuBar = ({ editor }: MenuBarProps) => {
const setLink = () => {
// Prompt the user to enter a URL
let url = prompt('URL')
@ -190,17 +167,6 @@ export const MenuBar = ({ editor }: MenuBarProps) => {
const unsetLink = () => editor.chain().focus().unsetLink().run()
const setImage = () => {
let url = prompt('URL')
if (url) {
if (!/^(http|https):\/\//i.test(url)) {
url = `https://${url}`
}
return editor.chain().focus().setImage({ src: url }).run()
}
return false
}
const buttons: MenuBarButtonProps[] = [
{
label: 'Bold',
@ -231,7 +197,7 @@ export const MenuBar = ({ editor }: MenuBarProps) => {
{
label: 'Paragraph',
isActive: editor.isActive('paragraph'),
onClick: () => editor.chain().focus().setParagraph().run()
onClick: () => editor.chain().focus().toggleStrike().run()
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
@ -264,11 +230,6 @@ export const MenuBar = ({ editor }: MenuBarProps) => {
isActive: editor.isActive('link'),
onClick: editor.isActive('link') ? unsetLink : setLink
},
{
label: 'Image',
isActive: editor.isActive('image'),
onClick: setImage
},
{
label: 'Horizontal rule',
onClick: () => editor.chain().focus().setHorizontalRule().run()
@ -323,55 +284,7 @@ const MenuBarButton = ({
onClick={onClick}
disabled={disabled}
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
type='button'
>
{label}
</button>
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
description?: string
error?: string
}
/**
* Uncontrolled input component with design classes, label, description and error support
*
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
* @param label
* @param description
* @param error
*
* @see {@link React.ComponentProps<'input'>}
*/
export const InputFieldUncontrolled = ({
label,
description,
error,
...rest
}: InputFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
<input className='inputMain' {...rest} />
{error && <InputError message={error} />}
</div>
)
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
}
export const CheckboxFieldUncontrolled = ({
label,
...rest
}: CheckboxFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
<input type='checkbox' className='CheckboxMain' {...rest} />
</div>
)

View File

@ -1,42 +0,0 @@
import { Addressable } from 'types'
import { abbreviateNumber } from 'utils'
import { Zap } from './Zap'
import { Reactions } from './Reactions'
type InteractionsProps = {
addressable: Addressable
commentCount: number
}
export const Interactions = ({
addressable,
commentCount
}: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 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.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap addressable={addressable} />
<Reactions addressable={addressable} />
</div>
</div>
)
}

View File

@ -1,86 +0,0 @@
import { formatDate } from 'date-fns'
type PublishDetailsProps = {
published_at: number
edited_at: number
site: string
}
export const PublishDetails = ({
published_at,
edited_at,
site
}: PublishDetailsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/MM/yyyy hh:mm:ss aa'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}

View File

@ -5,23 +5,24 @@ import { handleModImageError } from '../utils'
import { ModDetails } from 'types'
import { getModPageRoute } from 'routes'
import { kinds, nip19 } from 'nostr-tools'
import { useDidMount, useNDKContext, useReactions } from 'hooks'
import { useDidMount, useReactions } from 'hooks'
import { RelayController } from 'controllers'
import { toast } from 'react-toastify'
import { useComments } from 'hooks/useComments'
export const ModCard = React.memo((props: ModDetails) => {
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
const [commentCount, setCommentCount] = useState(0)
const { commentEvents } = useComments(props.author, props.aTag)
const { commentEvents } = useComments(props)
const { likesCount, disLikesCount } = useReactions({
pubkey: props.author,
eTag: props.id,
aTag: props.aTag
})
const { getTotalZapAmount } = useNDKContext()
useDidMount(() => {
getTotalZapAmount(props.author, props.id, props.aTag)
RelayController.getInstance()
.getTotalZapAmount(props.author, props.id, props.aTag)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
})
@ -50,13 +51,7 @@ export const ModCard = React.memo((props: ModDetails) => {
src={props.featuredImageUrl}
onError={handleModImageError}
className='cMMPicture'
alt={`featured image for mod ${props.title}`}
/>
{props.nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard'>
<p>NSFW</p>
</div>
)}
</div>
<div className='cMMBody'>
<h3 className='cMMBodyTitle'>{props.title}</h3>

View File

@ -13,7 +13,8 @@ import { toast } from 'react-toastify'
import { FixedSizeList as List } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../constants'
import { useAppSelector, useGames, useNDKContext } from '../hooks'
import { RelayController } from '../controllers'
import { useAppSelector, useGames } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
@ -28,7 +29,6 @@ import {
} from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner'
import { NDKEvent } from '@nostr-dev-kit/ndk'
interface FormErrors {
game?: string
@ -54,7 +54,6 @@ type ModFormProps = {
export const ModForm = ({ existingModData }: ModFormProps) => {
const location = useLocation()
const navigate = useNavigate()
const { ndk, publish } = useNDKContext()
const games = useGames()
const userState = useAppSelector((state) => state.user)
@ -192,7 +191,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
return
}
const uuid = formState.dTag || uuidv4()
const uuid = uuidv4()
const currentTimeStamp = now()
const aTag =
@ -204,7 +203,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
pubkey: hexPubkey,
content: formState.body,
tags: [
['d', uuid],
['d', formState.dTag || uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
@ -244,8 +243,9 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
@ -395,7 +395,6 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
name='nsfw'
isChecked={formState.nsfw}
handleChange={handleCheckboxChange}
type='stylized'
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
@ -764,9 +763,8 @@ const GameDropdown = ({
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Game</label>
<p className='labelDescriptionMain'>
Can't find the game you're looking for? You can temporarily publish the
mod under '(Unlisted Game)' and later edit it with the proper game name
once we add it.
Can't find the game you're looking for? You can temporarily publish the mod under '(Unlisted Game)' and
later edit it with the proper game name once we add it.
</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
@ -827,12 +825,10 @@ const GameDropdown = ({
</List>
</div>
</div>
</div>
</div>
{error && <InputError message={error} />}
<p className='labelDescriptionMain'>
Note: Please mention the game name in the body text of your mod post
(e.g., 'This is a mod for Game Name') so we know what to look for and
add.
<p className='labelDescriptionMain'>Note: Please mention the game name in the body text of your mod post (e.g., 'This is a mod for Game Name')
so we know what to look for and add.
</p>
</div>
)

View File

@ -1,20 +1,16 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import { useAppSelector } from 'hooks'
import React from 'react'
import { Dispatch, SetStateAction } from 'react'
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
type Props = {
author?: string | undefined
filterKey?: string | undefined
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter' }: Props) => {
({ filterOptions, setFilterOptions }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<div className='IBMSecMain'>
@ -65,12 +61,7 @@ export const ModFilter = React.memo(
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
const isOwnProfile =
author &&
userState.auth &&
userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
if (!isAdmin) return null
}
return (

View File

@ -4,17 +4,13 @@ import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext
} from '../hooks'
import { RelayController, UserRelaysType } from '../controllers'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
import { appRoutes, getProfilePageRoute } from '../routes'
import '../styles/author.css'
import '../styles/innerPage.css'
import '../styles/socialPosts.css'
import { UserRelaysType } from '../types'
import { UserProfile } from '../types'
import {
copyTextToClipboard,
hexToNpub,
@ -26,19 +22,37 @@ import {
import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { useProfile } from 'hooks/useProfile'
type Props = {
pubkey: string
}
export const ProfileSection = ({ pubkey }: Props) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
findMetadata(pubkey).then((res) => {
setProfile(res)
})
})
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
return (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile pubkey={pubkey} />
<Profile
pubkey={pubkey}
displayName={displayName}
about={about}
image={profile?.image}
nip05={profile?.nip05}
lud16={profile?.lud16}
/>
</div>
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
@ -90,18 +104,21 @@ export const ProfileSection = ({ pubkey }: Props) => {
type ProfileProps = {
pubkey: string
displayName: string
about: string
image?: string
nip05?: string
lud16?: string
}
export const Profile = ({ pubkey }: ProfileProps) => {
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
const image = profile?.image || FALLBACK_PROFILE_IMAGE
const nip05 = profile?.nip05
const lud16 = profile?.lud16
export const Profile = ({
pubkey,
displayName,
about,
image,
nip05,
lud16
}: ProfileProps) => {
const npub = hexToNpub(pubkey)
const handleCopy = async () => {
@ -116,20 +133,14 @@ export const Profile = ({ pubkey }: ProfileProps) => {
})
}
// Try to encode
let profileRoute = appRoutes.home
let nprofile: string | undefined
try {
const hexPubkey = npubToHex(pubkey)
nprofile = hexPubkey
? nip19.nprofileEncode({
pubkey: hexPubkey
})
: undefined
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
} catch (error) {
// Silently ignore and redirect to home
log(true, LogType.Error, 'Failed to encode profile.', error)
const hexPubkey = npubToHex(pubkey)
if (hexPubkey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: hexPubkey
})
)
}
return (
@ -146,7 +157,9 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<div
className='IBMSMSMSSS_Author_Top_PP'
style={{
background: `url('${image}') center / cover no-repeat`
background: `url('${
image || FALLBACK_PROFILE_IMAGE
}') center / cover no-repeat`
}}
></div>
</div>
@ -154,8 +167,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
<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' && (
{nip05 && (
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
)}
</div>
@ -188,12 +200,8 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
{typeof lud16 !== 'undefined' && (
<ZapButtonWithPopUp pubkey={pubkey} />
)}
<ProfileQRButtonWithPopUp pubkey={pubkey} />
{lud16 && <ZapButtonWithPopUp pubkey={pubkey} />}
</div>
</div>
</div>
@ -238,15 +246,17 @@ const posts: Post[] = [
]
type QRButtonWithPopUpProps = {
nprofile: string
pubkey: string
}
export const ProfileQRButtonWithPopUp = ({
nprofile
pubkey
}: QRButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
useBodyScrollDisable(isOpen)
const nprofile = nip19.nprofileEncode({
pubkey
})
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
@ -325,8 +335,6 @@ type ZapButtonWithPopUpProps = {
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
const [isOpen, setIsOpen] = useState(false)
useBodyScrollDisable(isOpen)
return (
<>
<div
@ -360,7 +368,7 @@ type FollowButtonProps = {
}
const FollowButton = ({ pubkey }: FollowButtonProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const { fetchEventFromUserRelays } = useNDKContext()
const [isFollowing, setIsFollowing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
@ -433,8 +441,9 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
if (!signedEvent) return false
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')

View File

@ -1,39 +0,0 @@
import { forwardRef } from 'react'
interface SearchInputProps {
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
handleSearch: () => void
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ handleKeyDown, handleSearch }, ref) => (
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={ref}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
)
)

View File

@ -1,9 +0,0 @@
import styles from '../styles/dotsSpinner.module.scss'
export const Spinner = () => (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
export const Dots = () => <span className={styles.loading}></span>

View File

@ -1,26 +0,0 @@
interface TabsProps {
tabs: string[]
tab: number
setTab: React.Dispatch<React.SetStateAction<number>>
}
export const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
return (
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSNav'>
{tabs.map((t, i) => {
return (
<button
key={t}
className={`btn btnMain IBMSMSMFSSNavBtn${
tab === i ? ' IBMSMSMFSSNavBtnActive' : ''
}`}
type='button'
onClick={() => setTab(i)}
>
{t}
</button>
)
})}
</div>
)
}

View File

@ -1,4 +1,3 @@
import { getRelayListForUser } from '@nostr-dev-kit/ndk'
import { QRCodeSVG } from 'qrcode.react'
import React, {
Dispatch,
@ -10,7 +9,7 @@ import React, {
} from 'react'
import Countdown, { CountdownRenderProps } from 'react-countdown'
import { toast } from 'react-toastify'
import { ZapController } from '../controllers'
import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
import '../styles/popup.css'
import { PaymentRequest, UserProfile } from '../types'
@ -252,7 +251,7 @@ export const ZapPopUp = ({
setHasZapped,
handleClose
}: ZapPopUpProps) => {
const { ndk, findMetadata } = useNDKContext()
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
@ -301,20 +300,6 @@ export const ZapPopUp = ({
return null
}
// Find the receiver's read relays.
const receiverRelays = await getRelayListForUser(receiver, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
@ -323,7 +308,6 @@ export const ZapPopUp = ({
receiverMetadata.lud16,
amount,
receiverMetadata.pubkey as string,
receiverRelays,
userHexKey,
message,
eventId,
@ -336,7 +320,7 @@ export const ZapPopUp = ({
.finally(() => {
setIsLoading(false)
})
}, [amount, message, userState, receiver, eventId, aTag, ndk, findMetadata])
}, [amount, message, userState, receiver, eventId, aTag])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
@ -498,7 +482,7 @@ export const ZapSplit = ({
setHasZapped,
handleClose
}: ZapSplitProps) => {
const { ndk, findMetadata } = useNDKContext()
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
@ -518,8 +502,8 @@ export const ZapSplit = ({
setAuthor(res)
})
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
findMetadata(adminNpubs[0]).then((res) => {
const metadataController = await MetadataController.getInstance()
findMetadata(metadataController.adminNpubs[0]).then((res) => {
setAdmin(res)
})
})
@ -573,30 +557,12 @@ export const ZapSplit = ({
const invoices = new Map<string, PaymentRequest>()
if (authorShare > 0 && author?.pubkey && author?.lud16) {
// Find the receiver's read relays.
const authorRelays = await getRelayListForUser(
author.pubkey as string,
ndk
)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
setLoadingSpinnerDesc('Generating invoice for author')
const invoice = await zapController
.getLightningPaymentRequest(
author.lud16,
authorShare,
author.pubkey as string,
authorRelays,
userHexKey,
message,
eventId,
@ -613,27 +579,12 @@ 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)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
setLoadingSpinnerDesc('Generating invoice for site owner')
const invoice = await zapController
.getLightningPaymentRequest(
admin.lud16,
adminShare,
admin.pubkey as string,
adminRelays,
userHexKey,
message,
eventId,

View File

@ -20,12 +20,6 @@ export const LANDING_PAGE_DATA = {
'Cyberpunk 2077',
'ELDEN RING',
'The Coffin of Andy and Leyley'
],
featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2573jhg9trsu6vgav9gnn4dffkzk2ww3yrjejnc2s'
]
}
// we use this object to check if a user has reacted positively or negatively to a post
@ -118,8 +112,7 @@ export const REACTIONS = {
export const MAX_MODS_PER_PAGE = 10
export const MAX_GAMES_PER_PAGE = 10
// todo: add game and mod fallback image here
export const FALLBACK_PROFILE_IMAGE =
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
export const PROFILE_BLOG_FILTER_LIMIT = 20

View File

@ -3,26 +3,24 @@ import NDK, {
NDKEvent,
NDKFilter,
NDKKind,
NDKList,
NDKRelaySet,
NDKSubscriptionCacheUsage,
NDKUser,
zapInvoiceFromEvent
NDKUser
} from '@nostr-dev-kit/ndk'
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'
import { MOD_FILTER_LIMIT, T_TAG_VALUE } from 'constants.ts'
import { UserRelaysType } from 'controllers'
import { Dexie } from 'dexie'
import { createContext, ReactNode, useEffect, useMemo } from 'react'
import { toast } from 'react-toastify'
import { ModDetails, MuteLists, UserProfile, UserRelaysType } from 'types'
import { ModDetails, UserProfile } from 'types'
import {
constructModListFromEvents,
hexToNpub,
log,
LogType,
npubToHex,
orderEventsChronologically,
timeout
orderEventsChronologically
} from 'utils'
type FetchModsOptions = {
@ -30,40 +28,28 @@ type FetchModsOptions = {
until?: number
since?: number
limit?: number
author?: string
}
export interface NDKContextType {
interface NDKContextType {
ndk: NDK
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
fetchEvent: (filter: NDKFilter) => Promise<NDKEvent | null>
fetchEvents: (filter: NDKFilter, relayUrls?: string[]) => Promise<NDKEvent[]>
fetchEvent: (
filter: NDKFilter,
relayUrls?: string[]
) => Promise<NDKEvent | null>
fetchEventsFromUserRelays: (
filter: NDKFilter | NDKFilter[],
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent[]>
fetchEventFromUserRelays: (
filter: NDKFilter | NDKFilter[],
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent | null>
findMetadata: (pubkey: string) => Promise<UserProfile>
getTotalZapAmount: (
user: string,
eTag: string,
aTag?: string,
currentLoggedInUser?: string
) => Promise<{
accumulatedZapAmount: number
hasZapped: boolean
}>
publish: (event: NDKEvent) => Promise<string[]>
getNSFWList: () => Promise<string[]>
getMuteLists: (pubkey?: string) => Promise<{
admin: MuteLists
user: MuteLists
}>
}
// Create the context with an initial value of `null`
@ -86,31 +72,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
}, [])
const addAdminRelays = async (ndk: NDK) => {
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
adminNpubs.forEach((npub) => {
const hexKey = npubToHex(npub)
if (hexKey) {
getRelayListForUser(hexKey, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.bothRelayUrls.forEach((url) =>
ndk.addExplicitRelay(url)
)
}
})
.catch((err) => {
log(
true,
LogType.Error,
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
err
)
})
}
})
}
const ndk = useMemo(() => {
localStorage.setItem('debug', '*')
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
@ -127,7 +88,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
],
cacheAdapter: dexieAdapter
})
addAdminRelays(ndk)
ndk.connect()
@ -148,17 +108,42 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
source,
until,
since,
limit,
author
limit
}: FetchModsOptions): Promise<ModDetails[]> => {
const relays = new Set<string>()
relays.add(import.meta.env.VITE_APP_RELAY)
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
const promises = adminNpubs.map((npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return null
return getRelayListForUser(hexKey, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.writeRelayUrls.forEach((url) => relays.add(url))
}
})
.catch((err) => {
log(
true,
LogType.Error,
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
err
)
})
})
await Promise.allSettled(promises)
// Define the filter criteria for fetching mods
const filter: NDKFilter = {
kinds: [NDKKind.Classified], // Specify the kind of events to fetch
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
'#t': [T_TAG_VALUE],
until, // Optional filter to fetch events until this timestamp
since, // Optional filter to fetch events from this timestamp
authors: author ? [author] : undefined // Optional filter to fetch events from only this author
since // Optional filter to fetch events from this timestamp
}
// If the source matches the current window location, add a filter condition
@ -167,10 +152,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
return ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.fetchEvents(
filter,
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true)
)
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
orderEventsChronologically(ndkEvents)
@ -193,79 +179,55 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
/**
* Asynchronously retrieves multiple event based on a provided filter.
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
const fetchEvents = async (filter: NDKFilter): Promise<NDKEvent[]> => {
return ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
return orderEventsChronologically(ndkEvents)
})
.catch((err) => {
// Log the error and show a notification if fetching fails
log(true, LogType.Error, 'An error occurred in fetching events', err)
toast.error('An error occurred in fetching events') // Show error notification
return [] // Return an empty array in case of an error
})
}
/**
* Asynchronously retrieves an event based on a provided filter.
*
* @param filter - The filter criteria to find the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
const fetchEvent = async (filter: NDKFilter) => {
const events = await fetchEvents(filter)
if (events.length === 0) return null
return events[0]
}
/**
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves with an array of events.
*/
const fetchEventsFromUserRelays = async (
filter: NDKFilter | NDKFilter[],
hexKey: string,
userRelaysType: UserRelaysType
const fetchEvents = async (
filter: NDKFilter,
relayUrls: string[] = []
): Promise<NDKEvent[]> => {
// Find the user's relays (10s timeout).
const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
log(
true,
LogType.Error,
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
)
return [] as string[]
})
const relays = new Set<string>()
// add all the relays passed to relay set
relayUrls.forEach((relayUrl) => {
relays.add(relayUrl)
})
relays.add(import.meta.env.VITE_APP_RELAY)
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
const promises = adminNpubs.map((npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return null
return getRelayListForUser(hexKey, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.writeRelayUrls.forEach((url) => relays.add(url))
}
})
.catch((err) => {
log(
true,
LogType.Error,
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
err
)
})
})
await Promise.allSettled(promises)
return ndk
.fetchEvents(
filter,
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true)
)
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
@ -279,6 +241,54 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
})
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relaysUrls - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
const fetchEvent = async (filter: NDKFilter, relayUrls: string[] = []) => {
const events = await fetchEvents(filter, relayUrls)
if (events.length === 0) return null
return events[0]
}
/**
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves with an array of events.
*/
const fetchEventsFromUserRelays = async (
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => {
// Find the user's relays.
const relayUrls = await getRelayListForUser(hexKey, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
log(
true,
LogType.Error,
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
)
return [] as string[]
})
// Fetch the event from the user's relays using the provided filter and relay URLs
return fetchEvents(filter, relayUrls)
}
/**
* Fetches an event from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
@ -289,7 +299,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
* @returns A promise that resolves to the fetched event or null if the operation fails.
*/
const fetchEventFromUserRelays = async (
filter: NDKFilter | NDKFilter[],
filter: NDKFilter,
hexKey: string,
userRelaysType: UserRelaysType
) => {
@ -319,178 +329,6 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
return userProfile
}
const getTotalZapAmount = async (
user: string,
eTag: string,
aTag?: string,
currentLoggedInUser?: string
) => {
const filters: NDKFilter[] = [
{
kinds: [NDKKind.Zap],
'#e': [eTag],
'#p': [user]
}
]
if (aTag) {
filters.push({
kinds: [NDKKind.Zap],
'#a': [aTag],
'#p': [user]
})
}
const zapEvents = await fetchEventsFromUserRelays(
filters,
user,
UserRelaysType.Read
)
let accumulatedZapAmount = 0
let hasZapped = false
zapEvents.forEach((zap) => {
const zapInvoice = zapInvoiceFromEvent(zap)
if (zapInvoice) {
accumulatedZapAmount += Math.round(zapInvoice.amount / 1000)
if (!hasZapped) hasZapped = zapInvoice.zappee === currentLoggedInUser
}
})
return {
accumulatedZapAmount,
hasZapped
}
}
const publish = async (event: NDKEvent): Promise<string[]> => {
if (!event.sig) throw new Error('Before publishing first sign the event!')
return event
.publish(undefined, 30000)
.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 []
})
}
/**
* Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin.
*
* @returns {Promise<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
*/
const getNSFWList = async (): Promise<string[]> => {
// Initialize an array to store the NSFW post identifiers
const nsfwPosts: string[] = []
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
// Convert the public key (npub) to a hexadecimal format
const hexKey = npubToHex(reportingNpub)
// If the conversion is successful and we have a hexKey
if (hexKey) {
// Fetch the event that contains the NSFW list
const nsfwListEvent = await fetchEvent({
kinds: [NDKKind.ArticleCurationSet],
authors: [hexKey],
'#d': ['nsfw']
})
if (nsfwListEvent) {
// Convert the event data to an NDKList, which is a structured list format
const list = NDKList.from(nsfwListEvent)
// Iterate through the items in the list
list.items.forEach((item) => {
if (item[0] === 'a') {
// Add the identifier of the NSFW post to the nsfwPosts array
nsfwPosts.push(item[1])
}
})
}
}
// Return the array of NSFW post identifiers
return nsfwPosts
}
const getMuteLists = async (
pubkey?: string
): Promise<{
admin: MuteLists
user: MuteLists
}> => {
const adminMutedAuthors = new Set<string>()
const adminMutedPosts = new Set<string>()
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const adminHexKey = npubToHex(reportingNpub)
if (adminHexKey) {
const muteListEvent = await fetchEvent({
kinds: [NDKKind.MuteList],
authors: [adminHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
if (item[0] === 'p') {
adminMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
adminMutedPosts.add(item[1])
}
})
}
}
const userMutedAuthors = new Set<string>()
const userMutedPosts = new Set<string>()
if (pubkey) {
const userHexKey = npubToHex(pubkey)
if (userHexKey) {
const muteListEvent = await fetchEvent({
kinds: [NDKKind.MuteList],
authors: [userHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
if (item[0] === 'p') {
userMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
userMutedPosts.add(item[1])
}
})
}
}
}
return {
admin: {
authors: Array.from(adminMutedAuthors),
replaceableEvents: Array.from(adminMutedPosts)
},
user: {
authors: Array.from(userMutedAuthors),
replaceableEvents: Array.from(userMutedPosts)
}
}
}
return (
<NDKContext.Provider
value={{
@ -500,11 +338,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
fetchEvent,
fetchEventsFromUserRelays,
fetchEventFromUserRelays,
findMetadata,
getTotalZapAmount,
publish,
getNSFWList,
getMuteLists
findMetadata
}}
>
{children}

View File

@ -1 +1,3 @@
export * from './metadata'
export * from './relay'
export * from './zap'

217
src/controllers/metadata.ts Normal file
View File

@ -0,0 +1,217 @@
import NDK, { getRelayListForUser, NDKList } from '@nostr-dev-kit/ndk'
import { kinds } from 'nostr-tools'
import { MuteLists } from '../types'
import { log, LogType, npubToHex, timeout } from '../utils'
export enum UserRelaysType {
Read = 'readRelayUrls',
Write = 'writeRelayUrls',
Both = 'bothRelayUrls'
}
/**
* Singleton class to manage metadata operations using NDK.
*/
export class MetadataController {
private static instance: MetadataController
private ndk: NDK
public adminNpubs: string[]
public adminRelays = new Set<string>()
public reportingNpub: string
private constructor() {
this.ndk = new NDK({
explicitRelayUrls: [
'wss://user.kindpag.es',
'wss://purplepag.es',
'wss://relay.damus.io/',
import.meta.env.VITE_APP_RELAY
]
})
this.ndk
.connect()
.then(() => {
console.log('NDK connected')
})
.catch((err) => {
console.log('error in ndk connection', err)
})
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB
}
private setAdminRelays = async () => {
const promises = this.adminNpubs.map((npub) => {
const hexKey = npubToHex(npub)
if (!hexKey) return null
return getRelayListForUser(hexKey, this.ndk)
.then((ndkRelayList) => {
if (ndkRelayList) {
ndkRelayList.writeRelayUrls.forEach((url) =>
this.adminRelays.add(url)
)
}
})
.catch((err) => {
log(
true,
LogType.Error,
`❌ Error occurred in getting the instance of NDKRelayList for npub: ${npub}`,
err
)
})
})
await Promise.allSettled(promises)
}
/**
* Provides the singleton instance of MetadataController.
*
* @returns The singleton instance of MetadataController.
*/
public static async getInstance(): Promise<MetadataController> {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
await MetadataController.instance.setAdminRelays()
}
return MetadataController.instance
}
public findUserRelays = async (
hexKey: string,
userRelaysType: UserRelaysType = UserRelaysType.Both
): Promise<string[]> => {
log(true, LogType.Info, ` Finding user's relays`, hexKey, userRelaysType)
const ndkRelayListPromise = getRelayListForUser(hexKey, this.ndk)
// Use Promise.race to either get the NDKRelayList instance or handle the timeout
return await Promise.race([
ndkRelayListPromise,
timeout() // Custom timeout function that rejects after a specified time
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
log(true, LogType.Error, err)
return [] // Return an empty array if an error occurs
})
}
public getNDKRelayList = async (hexKey: string) =>
getRelayListForUser(hexKey, this.ndk)
public getMuteLists = async (
pubkey?: string
): Promise<{
admin: MuteLists
user: MuteLists
}> => {
const adminMutedAuthors = new Set<string>()
const adminMutedPosts = new Set<string>()
const adminHexKey = npubToHex(this.reportingNpub)
if (adminHexKey) {
const muteListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Mutelist],
authors: [adminHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
if (item[0] === 'p') {
adminMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
adminMutedPosts.add(item[1])
}
})
}
}
const userMutedAuthors = new Set<string>()
const userMutedPosts = new Set<string>()
if (pubkey) {
const userHexKey = npubToHex(pubkey)
if (userHexKey) {
const muteListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Mutelist],
authors: [userHexKey]
})
if (muteListEvent) {
const list = NDKList.from(muteListEvent)
list.items.forEach((item) => {
if (item[0] === 'p') {
userMutedAuthors.add(item[1])
} else if (item[0] === 'a') {
userMutedPosts.add(item[1])
}
})
}
}
}
return {
admin: {
authors: Array.from(adminMutedAuthors),
replaceableEvents: Array.from(adminMutedPosts)
},
user: {
authors: Array.from(userMutedAuthors),
replaceableEvents: Array.from(userMutedPosts)
}
}
}
/**
* Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin.
*
* @returns {Promise<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
*/
public getNSFWList = async (): Promise<string[]> => {
// Initialize an array to store the NSFW post identifiers
const nsfwPosts: string[] = []
// Convert the public key (npub) to a hexadecimal format
const hexKey = npubToHex(this.reportingNpub)
// If the conversion is successful and we have a hexKey
if (hexKey) {
// Fetch the event that contains the NSFW list
const nsfwListEvent = await this.ndk.fetchEvent({
kinds: [kinds.Curationsets],
authors: [hexKey],
'#d': ['nsfw']
})
if (nsfwListEvent) {
// Convert the event data to an NDKList, which is a structured list format
const list = NDKList.from(nsfwListEvent)
// Iterate through the items in the list
list.items.forEach((item) => {
if (item[0] === 'a') {
// Add the identifier of the NSFW post to the nsfwPosts array
nsfwPosts.push(item[1])
}
})
}
}
// Return the array of NSFW post identifiers
return nsfwPosts
}
}

561
src/controllers/relay.ts Normal file
View File

@ -0,0 +1,561 @@
import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools'
import {
extractZapAmount,
log,
LogType,
normalizeWebSocketURL,
timeout
} from '../utils'
import { MetadataController, UserRelaysType } from './metadata'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
private events = new Map<string, Event>()
private debug = true
public connectedRelays: Relay[] = []
private constructor() {}
/**
* Provides the singleton instance of RelayController.
*
* @returns The singleton instance of RelayController.
*/
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
return RelayController.instance
}
public connectRelay = async (relayUrl: string) => {
const relay = this.connectedRelays.find(
(relay) =>
normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl)
)
if (relay) {
// already connected, skip
return relay
}
return await Relay.connect(relayUrl)
.then((relay) => {
log(this.debug, LogType.Info, `✅ nostr (${relayUrl}): Connected!`)
this.connectedRelays.push(relay)
return relay
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relayUrl}): Connection error!`,
err
)
return null
})
}
/**
* Publishes an event to multiple relays.
*
* This method establishes a connection to the application relay specified by
* an environment variable and a set of relays obtained from the
* `MetadataController`. It attempts to publish the event to all connected
* relays and returns a list of URLs of relays where the event was successfully
* published.
*
* If the process of finding relays or publishing the event takes too long,
* it handles the timeout to prevent blocking the operation.
*
* @param event - The event to be published.
* @param userHexKey - The user's hexadecimal public key, used to retrieve their relays.
* If not provided, the event's public key will be used.
* @param userRelaysType - The type of relays to be retrieved (e.g., write relays).
* Defaults to `UserRelaysType.Write`.
* @returns A promise that resolves to an array of URLs of relays where the event
* was published, or an empty array if no relays were connected or the
* event could not be published.
*/
publish = async (
event: Event,
userHexKey?: string,
userRelaysType?: UserRelaysType
): Promise<string[]> => {
// Connect to the application relay specified by an environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
// TODO: Implement logic to retrieve relays using `window.nostr.getRelays()` once it becomes available in nostr-login.
// Retrieve an instance of MetadataController to find user relays
const metadataController = await MetadataController.getInstance()
// Retrieve the list of relays for the specified user's public key
const relayUrls = await metadataController.findUserRelays(
userHexKey || event.pubkey,
userRelaysType || UserRelaysType.Write
)
// Add admin relay URLs from the metadata controller to the list of relay URLs
metadataController.adminRelays.forEach((url) => {
relayUrls.push(url)
})
// Attempt to connect to all write relays obtained from MetadataController
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Wait for all relay connection attempts to settle (either fulfilled or rejected)
const results = await Promise.allSettled([
appRelayPromise,
...relayPromises
])
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// If no relays are connected, log an error and return an empty array
if (relays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!')
return []
}
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
// Create promises to publish the event to each connected relay
const publishPromises = relays.map((relay) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Sending event:`,
event
)
return Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle slow publishing operations
])
.then((res) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Publish result:`,
res
)
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relay.url}): Publish error!`,
err
)
})
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
if (publishedOnRelays.length > 0) {
// If the event was successfully published to any relays, check if it contains an `aTag`
// If the `aTag` is present, cache the event locally
const aTag = event.tags.find((item) => item[0] === 'a')
if (aTag && aTag[1]) {
this.events.set(aTag[1], event)
}
}
// Return the list of relay URLs where the event was successfully published
return publishedOnRelays
}
/**
* Publishes an encrypted DM to receiver's read relays.
*
* This method connects to the application relay and a set of receiver's read relays
* obtained from the `MetadataController`. It then publishes the event to
* all connected relays and returns a list of relays where the event was successfully published.
*
* @param event - The event to be published.
* @returns A promise that resolves to an array of URLs of relays where the event was published,
* or an empty array if no relays were connected or the event could not be published.
*/
publishDM = async (event: Event, receiver: string): Promise<string[]> => {
// Connect to the application relay specified by environment variable
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
// todo: window.nostr.getRelays() is not implemented yet in nostr-login, implement the logic once its done
const metadataController = await MetadataController.getInstance()
// Retrieve the list of read relays for the receiver
const readRelayUrls = await metadataController.findUserRelays(
receiver,
UserRelaysType.Read
)
// push admin relay urls obtained from metadata controller to readRelayUrls list
metadataController.adminRelays.forEach((url) => {
readRelayUrls.push(url)
})
// Connect to all write relays obtained from MetadataController
const relayPromises = readRelayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Wait for all relay connections to settle (either fulfilled or rejected)
await Promise.allSettled([appRelayPromise, ...relayPromises])
// Check if any relays are connected; if not, log an error and return null
if (this.connectedRelays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!')
return []
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = this.connectedRelays.map((relay) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Sending event:`,
event
)
return Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle cases where publishing takes too long
])
.then((res) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Publish result:`,
res
)
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relay.url}): Publish error!`,
err
)
})
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
// Return the list of relay URLs where the event was published
return publishedOnRelays
}
/**
* Publishes an event to multiple relays.
*
* This method establishes a connection to the application relay specified by
* an environment variable and a set of relays provided as argument.
* It attempts to publish the event to all connected relays
* and returns a list of URLs of relays where the event was successfully published.
*
* If the process of publishing the event takes too long,
* it handles the timeout to prevent blocking the operation.
*
* @param event - The event to be published.
* @param relayUrls - The array of relayUrl where event should be published
* @returns A promise that resolves to an array of URLs of relays where the event
* was published, or an empty array if no relays were connected or the
* event could not be published.
*/
publishOnRelays = async (
event: Event,
relayUrls: string[]
): Promise<string[]> => {
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
log(this.debug, LogType.Error, 'No relay is connected!')
return []
}
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
// Create promises to publish the event to each connected relay
const publishPromises = relays.map((relay) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Sending event:`,
event
)
return Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle slow publishing operations
])
.then((res) => {
log(
this.debug,
LogType.Info,
`⬆️ nostr (${relay.url}): Publish result:`,
res
)
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
})
.catch((err) => {
log(
this.debug,
LogType.Error,
`❌ nostr (${relay.url}): Publish error!`,
err
)
})
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
if (publishedOnRelays.length > 0) {
// If the event was successfully published to any relays, check if it contains an `aTag`
// If the `aTag` is present, cache the event locally
const aTag = event.tags.find((item) => item[0] === 'a')
if (aTag && aTag[1]) {
this.events.set(aTag[1], event)
}
}
// Return the list of relay URLs where the event was successfully published
return publishedOnRelays
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`APP_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subscriptions = relays.map((relay) =>
relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
}
})
)
return subscriptions
}
getTotalZapAmount = async (
user: string,
eTag: string,
aTag?: string,
currentLoggedInUser?: string
) => {
const metadataController = await MetadataController.getInstance()
const relayUrls = await metadataController.findUserRelays(
user,
UserRelaysType.Read
)
const appRelay = import.meta.env.VITE_APP_RELAY
if (!relayUrls.includes(appRelay)) {
relayUrls.push(appRelay)
}
// Connect to all specified relays
const relayPromises = relayUrls.map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
let accumulatedZapAmount = 0
let hasZapped = false
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
const filters: Filter[] = [
{
kinds: [kinds.Zap],
'#e': [eTag]
}
]
if (aTag) {
filters.push({
kinds: [kinds.Zap],
'#a': [aTag]
})
}
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe(filters, {
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
eventIds.add(e.id) // Record the event ID
const zapRequestStr = e.tags.find(
(t) => t[0] === 'description'
)?.[1]
if (!zapRequestStr) return
const error = nip57.validateZapRequest(zapRequestStr)
if (error) return
let zapRequest: Event | null = null
try {
zapRequest = JSON.parse(zapRequestStr)
} catch (error) {
log(
true,
LogType.Error,
'Error occurred in parsing zap request',
error
)
}
if (!zapRequest) return
const amount = extractZapAmount(zapRequest)
accumulatedZapAmount += amount
if (amount > 0) {
if (!hasZapped) {
hasZapped = zapRequest.pubkey === currentLoggedInUser
}
}
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
return {
accumulatedZapAmount,
hasZapped
}
}
}

View File

@ -17,6 +17,7 @@ import {
ZapRequest
} from '../types'
import { log, LogType, npubToHex } from '../utils'
import { MetadataController, UserRelaysType } from './metadata'
/**
* Singleton class to manage zap related operations.
@ -47,7 +48,6 @@ export class ZapController {
* @param lud16 - LUD-16 of the recipient.
* @param amount - payment amount (will be multiplied by 1000 to represent sats).
* @param recipientPubKey - pubKey of the recipient.
* @param recipientRelays - relays on which zap receipt will be published.
* @param senderPubkey - pubKey of of the sender.
* @param content - optional content (comment).
* @param eventId - event id, if zapping an event.
@ -59,7 +59,6 @@ export class ZapController {
lud16: string,
amount: number,
recipientPubKey: string,
recipientRelays: string[],
senderPubkey: string,
content?: string,
eventId?: string,
@ -89,7 +88,6 @@ export class ZapController {
amount,
content,
recipientPubKey,
recipientRelays,
senderPubkey,
eventId,
aTag
@ -275,7 +273,6 @@ export class ZapController {
* @param amount - request amount (sats).
* @param content - comment.
* @param recipientPubKey - pubKey of the recipient.
* @param recipientRelays - relays on which zap receipt will be published.
* @param senderPubkey - pubKey of of the sender.
* @param eventId - event id, if zapping an event.
* @param aTag - value of `a` tag.
@ -285,7 +282,6 @@ export class ZapController {
amount: number,
content = '',
recipientPubKey: string,
recipientRelays: string[],
senderPubkey: string,
eventId?: string,
aTag?: string
@ -294,15 +290,21 @@ export class ZapController {
if (!recipientHexKey) throw 'Invalid recipient pubKey.'
if (!recipientRelays.includes(this.appRelay)) {
recipientRelays.push(this.appRelay)
const metadataController = await MetadataController.getInstance()
const receiverReadRelays = await metadataController.findUserRelays(
recipientHexKey,
UserRelaysType.Read
)
if (!receiverReadRelays.includes(this.appRelay)) {
receiverReadRelays.push(this.appRelay)
}
const zapRequest: ZapRequest = {
kind: kinds.ZapRequest,
content,
tags: [
['relays', ...recipientRelays],
['relays', ...receiverReadRelays],
['amount', `${amount}`],
['p', recipientHexKey]
],

View File

@ -6,5 +6,3 @@ export * from './useMuteLists'
export * from './useNSFWList'
export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'

View File

@ -6,33 +6,22 @@ import {
NDKSubscription,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { UserRelaysType } from 'controllers'
import { useEffect, useState } from 'react'
import { CommentEvent, UserRelaysType } from 'types'
import { log, LogType, timeout } from 'utils'
import { CommentEvent, ModDetails } from 'types'
import { log, LogType } from 'utils'
import { useNDKContext } from './useNDKContext'
export const useComments = (
author: string | undefined,
aTag: string | undefined
) => {
export const useComments = (mod: ModDetails) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => {
if (!(author && aTag)) {
// Author and aTag are required
return
}
let subscription: NDKSubscription // Define the subscription variable here for cleanup
const setupSubscription = async () => {
// Find the mod author's relays.
const authorReadRelays = await Promise.race([
getRelayListForUser(author, ndk),
timeout(10 * 1000) // add a 10 sec timeout
])
const authorReadRelays = await getRelayListForUser(mod.author, ndk)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[UserRelaysType.Read]
return [] // Return an empty array if ndkRelayList is undefined
@ -41,7 +30,7 @@ export const useComments = (
log(
true,
LogType.Error,
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
`An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`,
err
)
return [] as string[]
@ -49,24 +38,16 @@ export const useComments = (
const filter: NDKFilter = {
kinds: [NDKKind.Text],
'#a': [aTag]
'#a': [mod.aTag]
}
const relayUrls = new Set<string>()
ndk.pool.urls().forEach((relayUrl) => {
relayUrls.add(relayUrl)
})
authorReadRelays.forEach((relayUrl) => relayUrls.add(relayUrl))
subscription = ndk.subscribe(
filter,
{
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
},
NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
NDKRelaySet.fromRelayUrls(authorReadRelays, ndk, true)
)
subscription.on('event', (ndkEvent) => {
@ -100,7 +81,7 @@ export const useComments = (
subscription.stop()
}
}
}, [aTag, author, ndk])
}, [mod.aTag, mod.author, ndk])
return {
commentEvents,

View File

@ -8,7 +8,6 @@ import {
NSFWFilter,
SortBy
} from 'types'
import { npubToHex } from 'utils'
export const useFilteredMods = (
mods: ModDetails[],
@ -18,20 +17,10 @@ export const useFilteredMods = (
muteLists: {
admin: MuteLists
user: MuteLists
},
author?: string | undefined
}
) => {
return useMemo(() => {
const nsfwFilter = (mods: ModDetails[]) => {
// Add nsfw tag to mods included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
mods = mods.map((mod) => {
return !mod.nsfw && nsfwList.includes(mod.aTag)
? { ...mod, nsfw: true }
: mod
})
}
// Determine the filtering logic based on the NSFW filter option
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
@ -49,15 +38,11 @@ export const useFilteredMods = (
let filtered = nsfwFilter(mods)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.npub &&
npubToHex(userState.user.npub as string) === author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// 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 (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
@ -85,7 +70,6 @@ export const useFilteredMods = (
filterOptions.sort,
filterOptions.moderated,
filterOptions.nsfw,
author,
mods,
muteLists,
nsfwList

View File

@ -1,50 +0,0 @@
import React from 'react'
import {
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem
} from 'utils'
const useLocalStorageSubscribe = (callback: () => void) => {
window.addEventListener('storage', callback)
return () => window.removeEventListener('storage', callback)
}
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const getSnapshot = () => getLocalStorageItem(key, initialValue)
const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot)
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
(v: React.SetStateAction<T>) => {
try {
const nextState =
typeof v === 'function'
? (v as (prevState: T) => T)(JSON.parse(data))
: v
if (nextState === undefined || nextState === null) {
removeLocalStorageItem(key)
} else {
setLocalStorageItem(key, JSON.stringify(nextState))
}
} catch (e) {
console.warn(e)
}
},
[key, data]
)
React.useEffect(() => {
// Set local storage only when it's empty
const data = window.localStorage.getItem(key)
if (data === null) {
setLocalStorageItem(key, JSON.stringify(initialValue))
}
}, [key, initialValue])
return [JSON.parse(data) as T, setState]
}

View File

@ -1,10 +1,9 @@
import { useEffect, useState } from 'react'
import { MuteLists } from 'types'
import { useAppSelector } from './redux'
import { useNDKContext } from './useNDKContext'
import { MetadataController } from 'controllers'
export const useMuteLists = () => {
const { getMuteLists } = useNDKContext()
const [muteLists, setMuteLists] = useState<{
admin: MuteLists
user: MuteLists
@ -22,11 +21,17 @@ export const useMuteLists = () => {
const userState = useAppSelector((state) => state.user)
useEffect(() => {
const pubkey = userState.user?.pubkey as string | undefined
getMuteLists(pubkey).then((lists) => {
setMuteLists(lists)
})
}, [userState, getMuteLists])
const getMuteLists = async () => {
const pubkey = userState.user?.pubkey as string | undefined
const metadataController = await MetadataController.getInstance()
metadataController.getMuteLists(pubkey).then((lists) => {
setMuteLists(lists)
})
}
getMuteLists()
}, [userState])
return muteLists
}

View File

@ -1,4 +1,4 @@
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
import { NDKContext } from 'contexts/NDKContext'
import { useContext } from 'react'
export const useNDKContext = () => {
@ -9,5 +9,23 @@ export const useNDKContext = () => {
'NDKContext should not be used in out component tree hierarchy'
)
return { ...ndkContext } as NDKContextType
const {
ndk,
fetchEvents,
fetchEvent,
fetchEventsFromUserRelays,
fetchEventFromUserRelays,
fetchMods,
findMetadata
} = ndkContext
return {
ndk,
fetchEvents,
fetchEvent,
fetchEventsFromUserRelays,
fetchEventFromUserRelays,
fetchMods,
findMetadata
}
}

View File

@ -1,13 +1,14 @@
import { MetadataController } from 'controllers'
import { useState } from 'react'
import { useDidMount } from './useDidMount'
import { useNDKContext } from './useNDKContext'
export const useNSFWList = () => {
const { getNSFWList } = useNDKContext()
const [nsfwList, setNSFWList] = useState<string[]>([])
useDidMount(async () => {
getNSFWList().then((list) => {
const metadataController = await MetadataController.getInstance()
metadataController.getNSFWList().then((list) => {
setNSFWList(list)
})
})

View File

@ -1,18 +0,0 @@
import { useNDKContext } from 'hooks'
import { useState, useEffect } from 'react'
import { UserProfile } from 'types'
export const useProfile = (pubkey?: string) => {
const { findMetadata } = useNDKContext()
const [profile, setProfile] = useState<UserProfile>()
useEffect(() => {
if (pubkey) {
findMetadata(pubkey).then((res) => {
setProfile(res)
})
}
}, [findMetadata, pubkey])
return profile
}

View File

@ -1,11 +1,11 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'
import { REACTIONS } from 'constants.ts'
import { RelayController, UserRelaysType } from 'controllers'
import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
import { Event, kinds, UnsignedEvent } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import { UserRelaysType } from 'types'
import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
import { abbreviateNumber, log, LogType, now } from 'utils'
type UseReactionsParams = {
pubkey: string
@ -14,7 +14,7 @@ type UseReactionsParams = {
}
export const useReactions = (params: UseReactionsParams) => {
const { ndk, fetchEventsFromUserRelays, publish } = useNDKContext()
const { ndk, fetchEventsFromUserRelays } = useNDKContext()
const [isReactionInProgress, setIsReactionInProgress] = useState(false)
const [isDataLoaded, setIsDataLoaded] = useState(false)
const [reactionEvents, setReactionEvents] = useState<NDKEvent[]>([])
@ -32,11 +32,7 @@ export const useReactions = (params: UseReactionsParams) => {
filter['#e'] = [params.eTag]
}
// 1 minute timeout
Promise.race([
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
timeout(60000)
])
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
.then((events) => {
setReactionEvents(events)
})
@ -123,11 +119,13 @@ export const useReactions = (params: UseReactionsParams) => {
if (!signedEvent) return
const ndkEvent = new NDKEvent(ndk, signedEvent)
setReactionEvents((prev) => [...prev, new NDKEvent(ndk, signedEvent)])
setReactionEvents((prev) => [...prev, ndkEvent])
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event,
params.pubkey,
UserRelaysType.Read
)
if (publishedOnRelays.length === 0) {
log(

View File

@ -1,11 +0,0 @@
import { useEffect } from 'react'
export const useBodyScrollDisable = (disable: boolean) => {
useEffect(() => {
if (disable) document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [disable])
}

View File

@ -1,10 +0,0 @@
import { Outlet } from 'react-router-dom'
export const FeedLayout = () => {
return (
<div className='InnerBodyMain'>
<h1>WIP</h1>
<Outlet />
</div>
)
}

View File

@ -6,10 +6,10 @@ import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Banner } from '../components/Banner'
import { ZapPopUp } from '../components/Zap'
import { MetadataController } from '../controllers'
import {
useAppDispatch,
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext
} from '../hooks'
@ -27,18 +27,6 @@ export const Header = () => {
const { findMetadata } = useNDKContext()
const userState = useAppSelector((state) => state.user)
// Track nostr-login extension modal open state
const [isOpen, setIsOpen] = useState(false)
const handleOpen = () => setIsOpen(true)
const handleClose = () => setIsOpen(false)
useEffect(() => {
window.addEventListener('nlCloseModal', handleClose)
return () => {
window.removeEventListener('nlCloseModal', handleClose)
}
}, [])
useBodyScrollDisable(isOpen)
useEffect(() => {
initNostrLogin({
darkMode: true,
@ -78,7 +66,6 @@ export const Header = () => {
}, [dispatch, findMetadata])
const handleLogin = () => {
handleOpen()
launchNostrLoginDialog()
}
@ -212,7 +199,7 @@ export const Header = () => {
About
</Link>
<Link
to={appRoutes.blogs}
to={appRoutes.blog}
className={navStyles.NavMainBottomInsideLink}
>
Blog
@ -273,11 +260,9 @@ const TipButtonWithDialog = React.memo(() => {
const [adminNpub, setAdminNpub] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
useBodyScrollDisable(isOpen)
useDidMount(async () => {
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
setAdminNpub(adminNpubs[0])
const metadataController = await MetadataController.getInstance()
setAdminNpub(metadataController.adminNpubs[0])
})
return (
@ -336,8 +321,6 @@ const TipButtonWithDialog = React.memo(() => {
const RegisterButtonWithDialog = () => {
const [showPopUp, setShowPopUp] = useState(false)
useBodyScrollDisable(showPopUp)
return (
<>
<a
@ -379,14 +362,16 @@ const RegisterButtonWithDialog = () => {
Browser Extensions (Windows)
</label>
<p className='labelDescriptionMain'>
Once you create your "account" on any of these, come back and click login, then sign-in with
extension. Here's a quick video guide, and here's a <a
href='https://degmods.com/blog/naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c'
>guide post</a> to help with this process.</p>
<div style={{ width: '100%', height: 'auto', borderRadius: '8px', overflow: 'hidden' }}>
<video controls style={{ width: '100%' }}><source src="https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4" type="video/mp4" />
Your browser does not support the video tag.</video>
</div>
Once you create your "account" on any of these (
<a
href='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4'
target='blank_'
>
Here's a quick video guide
</a>
), come back and click login, then sign-in with
extension.
</p>
</div>
<a
className='btn btnMain btnMainPopup'

View File

@ -1,4 +1,4 @@
import { Outlet, ScrollRestoration } from 'react-router-dom'
import { Outlet } from 'react-router-dom'
import { Footer } from './footer'
import { Header } from './header'
import { SocialNav } from './socialNav'
@ -12,7 +12,6 @@ export const Layout = () => {
<Outlet />
<Footer />
<SocialNav />
<ScrollRestoration />
</>
)
}

View File

@ -1,32 +1,24 @@
import { useAppSelector } from 'hooks'
import { nip19 } from 'nostr-tools'
import { useState } from 'react'
import { NavLink, NavLinkProps } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import 'styles/socialNav.css'
import { npubToHex } from 'utils'
export const SocialNav = () => {
const location = useLocation()
const currentPath = location.pathname
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
const userState = useAppSelector((state) => state.user)
let profileRoute = ''
if (userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
const toggleNav = () => {
setIsCollapsed(!isCollapsed)
}
const isOnHomePage =
currentPath === appRoutes.index || currentPath === appRoutes.home
const isOnSearchPage = currentPath === appRoutes.search
const isOnProfilePage = new RegExp(
`^${appRoutes.profile.replace(':nprofile', '[^/]+')}$`
).test(currentPath)
return (
<div
className='socialNav'
@ -40,27 +32,30 @@ export const SocialNav = () => {
<div className='socialNavInside'>
<NavButton
to={appRoutes.home}
viewBox='0 -32 576 576'
isActive={isOnHomePage}
viewBox="0 -32 576 576"
svgPath='M511.8 287.6L512.5 447.7C512.5 450.5 512.3 453.1 512 455.8V472C512 494.1 494.1 512 472 512H456C454.9 512 453.8 511.1 452.7 511.9C451.3 511.1 449.9 512 448.5 512H392C369.9 512 352 494.1 352 472V384C352 366.3 337.7 352 320 352H256C238.3 352 224 366.3 224 384V472C224 494.1 206.1 512 184 512H128.1C126.6 512 125.1 511.9 123.6 511.8C122.4 511.9 121.2 512 120 512H104C81.91 512 64 494.1 64 472V360C64 359.1 64.03 358.1 64.09 357.2V287.6H32.05C14.02 287.6 0 273.5 0 255.5C0 246.5 3.004 238.5 10.01 231.5L266.4 8.016C273.4 1.002 281.4 0 288.4 0C295.4 0 303.4 2.004 309.5 7.014L416 100.7V64C416 46.33 430.3 32 448 32H480C497.7 32 512 46.33 512 64V185L564.8 231.5C572.8 238.5 576.9 246.5 575.8 255.5C575.8 273.5 560.8 287.6 543.8 287.6L511.8 287.6z'
/>
<NavButton
to={appRoutes.feed}
to={appRoutes.home}
isActive={false}
svgPath='M88 48C101.3 48 112 58.75 112 72V120C112 133.3 101.3 144 88 144H40C26.75 144 16 133.3 16 120V72C16 58.75 26.75 48 40 48H88zM480 64C497.7 64 512 78.33 512 96C512 113.7 497.7 128 480 128H192C174.3 128 160 113.7 160 96C160 78.33 174.3 64 192 64H480zM480 224C497.7 224 512 238.3 512 256C512 273.7 497.7 288 480 288H192C174.3 288 160 273.7 160 256C160 238.3 174.3 224 192 224H480zM480 384C497.7 384 512 398.3 512 416C512 433.7 497.7 448 480 448H192C174.3 448 160 433.7 160 416C160 398.3 174.3 384 192 384H480zM16 232C16 218.7 26.75 208 40 208H88C101.3 208 112 218.7 112 232V280C112 293.3 101.3 304 88 304H40C26.75 304 16 293.3 16 280V232zM88 368C101.3 368 112 378.7 112 392V440C112 453.3 101.3 464 88 464H40C26.75 464 16 453.3 16 440V392C16 378.7 26.75 368 40 368H88z'
/>
<NavButton
to={appRoutes.notifications}
to={appRoutes.home}
isActive={false}
svgPath='M256 32V51.2C329 66.03 384 130.6 384 208V226.8C384 273.9 401.3 319.2 432.5 354.4L439.9 362.7C448.3 372.2 450.4 385.6 445.2 397.1C440 408.6 428.6 416 416 416H32C19.4 416 7.971 408.6 2.809 397.1C-2.353 385.6-.2883 372.2 8.084 362.7L15.5 354.4C46.74 319.2 64 273.9 64 226.8V208C64 130.6 118.1 66.03 192 51.2V32C192 14.33 206.3 0 224 0C241.7 0 256 14.33 256 32H256zM224 512C207 512 190.7 505.3 178.7 493.3C166.7 481.3 160 464.1 160 448H288C288 464.1 281.3 481.3 269.3 493.3C257.3 505.3 240.1 512 224 512z'
/>
<NavButton
to={appRoutes.search}
isActive={isOnSearchPage}
svgPath='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'
/>
{!!userState.auth && (
<NavButton
to={profileRoute}
svgPath='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'
/>
)}
<NavButton
to={getProfilePageRoute('xyz')}
isActive={isOnProfilePage}
svgPath='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'
/>
</div>
)}
<div className='socialNavCollapse' onClick={toggleNav}>
@ -83,23 +78,19 @@ export const SocialNav = () => {
)
}
interface NavButtonProps extends NavLinkProps {
interface NavButtonProps {
to: string
isActive: boolean
svgPath: string
viewBox?: string
}
const NavButton = ({
svgPath,
viewBox = '0 0 512 512',
...rest
}: NavButtonProps) => (
<NavLink
{...rest}
className={({ isActive }) =>
`btn btnMain socialNavInsideBtn ${
isActive ? 'socialNavInsideBtnActive' : ''
}`
}
const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButtonProps) => (
<Link
to={to}
className={`btn btnMain socialNavInsideBtn ${
isActive ? 'socialNavInsideBtnActive' : ''
}`}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -110,5 +101,7 @@ const NavButton = ({
>
<path d={svgPath}></path>
</svg>
</NavLink>
)
</Link>
);

View File

@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { HashRouter } from 'react-router-dom'
import { ToastContainer } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import App from './App.tsx'
@ -11,10 +12,12 @@ import { NDKContextProvider } from 'contexts/NDKContext.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<NDKContextProvider>
<App />
</NDKContextProvider>
<ToastContainer />
<HashRouter>
<NDKContextProvider>
<App />
</NDKContextProvider>
<ToastContainer />
</HashRouter>
</Provider>
</React.StrictMode>
)

View File

@ -1,40 +0,0 @@
import { Link, useRouteError } from 'react-router-dom'
import { appRoutes } from 'routes'
interface NotFoundPageProps {
title: string
message: string
}
export const NotFoundPage = ({
title = 'Page not found',
message = "The page you're attempting to visit doesn't exist"
}: Partial<NotFoundPageProps>) => {
const error = useRouteError() as Partial<NotFoundPageProps>
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{error?.title || title}</h2>
</div>
<div>
<p>{error?.message || message}</p>
</div>
<div className='IBMSMAction'>
<Link
to={appRoutes.home}
className='btn btnMain IBMSMActionBtn'
type='button'
>
Go home
</Link>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,264 +0,0 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { ActionFunctionArgs } from 'react-router-dom'
import { toast } from 'react-toastify'
import { store } from 'store'
import { UserRelaysType } from 'types'
import { log, LogType, now, signAndPublish } from 'utils'
export const blogRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return null
}
// Decode author from naddr
let aTag: string | undefined
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
aTag = `${kind}:${pubkey}:${identifier}`
} catch (error) {
log(true, LogType.Error, 'Failed to decode naddr')
return null
}
if (!aTag) {
log(true, LogType.Error, 'Missing #a Tag')
return null
}
const userState = store.getState().user
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
}
if (!hexPubkey) {
toast.error('Failed to get the pubkey')
return null
}
const isAdmin =
userState.user?.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const handleBlock = async () => {
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [kinds.Mutelist],
authors: [hexPubkey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
if (alreadyExists) {
toast.warn(`Blog reference is already in user's mute list`)
return null
}
tags.push(['a', aTag])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: kinds.Mutelist,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: hexPubkey,
kind: kinds.Mutelist,
content: '',
created_at: now(),
tags: [['a', aTag]]
}
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's mute list")
}
return null
}
const handleUnblock = async () => {
const filter: NDKFilter = {
kinds: [kinds.Mutelist],
authors: [hexPubkey]
}
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`)
return null
}
const tags = muteListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: kinds.Mutelist,
content: muteListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's mute list")
}
return null
}
const handleAddNSFW = async () => {
const filter: NDKFilter = {
kinds: [kinds.Curationsets],
authors: [hexPubkey],
'#d': ['nsfw']
}
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (nsfwListEvent) {
const tags = nsfwListEvent.tags
const alreadyExists =
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
if (alreadyExists) {
toast.warn(`Blog reference is already in user's nsfw list`)
return null
}
tags.push(['a', aTag])
unsignedEvent = {
pubkey: nsfwListEvent.pubkey,
kind: kinds.Curationsets,
content: nsfwListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: hexPubkey,
kind: kinds.Curationsets,
content: '',
created_at: now(),
tags: [
['a', aTag],
['d', 'nsfw']
]
}
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's nsfw list")
}
return null
}
const handleRemoveNSFW = async () => {
const filter: NDKFilter = {
kinds: [kinds.Curationsets],
authors: [hexPubkey],
'#d': ['nsfw']
}
const nsfwListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
if (!nsfwListEvent) {
toast.error(`Couldn't get nsfw list event from relays`)
return null
}
const tags = nsfwListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: nsfwListEvent.pubkey,
kind: kinds.Curationsets,
content: nsfwListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'a' || item[1] !== aTag)
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
if (!isUpdated) {
toast.error("Failed to update user's nsfw list")
}
return null
}
const requestData = (await request.json()) as {
intent: 'nsfw' | 'block'
value: boolean
}
switch (requestData.intent) {
case 'block':
await (requestData.value ? handleBlock() : handleUnblock())
break
case 'nsfw':
if (!isAdmin) {
log(true, LogType.Error, 'Unable to update NSFW list. No permission')
return null
}
await (requestData.value ? handleAddNSFW() : handleRemoveNSFW())
break
default:
log(true, LogType.Error, 'Missing intent for blog action')
break
}
return null
}

View File

@ -1,323 +0,0 @@
import { useState } from 'react'
import {
useLoaderData,
Link as ReactRouterLink,
useNavigation,
useSubmit
} from 'react-router-dom'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { EditorContent, useEditor } from '@tiptap/react'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ProfileSection } from 'components/ProfileSection'
import { Comments } from 'components/comment'
import { Addressable, BlogPageLoaderResult } from 'types'
import placeholder from '../../assets/img/DEGMods Placeholder Img.png'
import { PublishDetails } from 'components/Internal/PublishDetails'
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 { ReportPopup } from './report'
export const BlogPage = () => {
const { blog, latest, isAddedToNSFW, isBlocked } =
useLoaderData() as BlogPageLoaderResult
const userState = useAppSelector((state) => state.user)
const isAdmin =
userState.user?.npub &&
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const navigation = useNavigation()
const [commentCount, setCommentCount] = useState(0)
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const editor = useEditor(
{
content: sanitized,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
editable: false
},
[sanitized]
)
const [showReportPopUp, setShowReportPopUp] = useState(false)
useBodyScrollDisable(showReportPopUp)
const submit = useSubmit()
const handleBlock = () => {
if (navigation.state === 'idle') {
submit(
{
intent: 'block',
value: !isBlocked,
target: blog?.aTag || ''
},
{
method: 'post',
encType: 'application/json'
}
)
}
}
const handleNSFW = () => {
if (navigation.state === 'idle') {
submit(
{
intent: 'nsfw',
value: !isAddedToNSFW,
target: blog?.aTag || ''
},
{
method: 'post',
encType: 'application/json'
}
)
}
}
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
{blog && (
<>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost'>
<div
className='dropdown dropdownMain dropdownMainBlogpost'
style={{ flexGrow: 'unset' }}
>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div
className={`dropdown-menu dropdown-menu-end dropdownMainMenu`}
>
{userState.auth &&
userState.user?.pubkey === blog.author && (
<ReactRouterLink
className='dropdown-item dropdownMainMenuItem'
to={'edit'}
>
<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='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</ReactRouterLink>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then(
(isCopied) => {
if (isCopied)
toast.success('Url copied to clipboard!')
}
)
}}
>
<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='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a
className='dropdown-item dropdownMainMenuItem'
href='#'
>
<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='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</a>
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
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'
>
<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}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
{isBlocked ? 'Unblock' : 'Block'} Blog
</a>
{isAdmin && (
<a
className='dropdown-item dropdownMainMenuItem'
onClick={handleNSFW}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 65.13-83.3c10.14-2.656 19.94 4.78 19.94 15.27c0 6.941-4.469 13.16-11.16 15.19c-17.5 4.578-34.41 23.94-34.41 52.84c0 50.81 39.31 94.81 91.41 94.81c24.66 0 45.22-6.5 63.19-18.75c11.75-8 27.91 3.469 23.91 16.69C314.6 384.7 309.8 388.4 304.1 391.9z'></path>
</svg>
{isAddedToNSFW ? 'Un-mark' : 'Mark'} as NSFW
</a>
)}
</div>
</div>
<div
className='IBMSMSMBSSPostPicture'
style={{
background: `url("${
blog.image !== '' ? blog.image : placeholder
}") center / cover no-repeat`
}}
></div>
<div className='IBMSMSMBSSPostInside'>
<div className='IBMSMSMBSSPostTitle'>
<h1 className='IBMSMSMBSSPostTitleHeading'>
{blog.title}
</h1>
</div>
<div className='IBMSMSMBSSPostBody'>
<EditorContent editor={editor} />
</div>
<div className='IBMSMSMBSSTags'>
{blog.nsfw && (
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW'>
<p>NSFW</p>
</div>
)}
{blog.tTags &&
blog.tTags.map((t) => (
<a key={t} className='IBMSMSMBSSTagsTag'>
{t}
</a>
))}
</div>
</div>
</div>
<Interactions
addressable={blog as Addressable}
commentCount={commentCount}
/>
<PublishDetails
published_at={blog.published_at || 0}
edited_at={blog.edited_at || 0}
site={blog.rTag || 'N/A'}
/>
{!!latest.length && (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>
Latest blog posts
</h4>
<div className='IBMSMList IBMSMListAlt'>
{latest.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
</div>
</div>
)}
<div className='IBMSMSplitMainBigSideSec'>
<Comments
key={blog.id}
addressable={blog as Addressable}
setCommentCount={setCommentCount}
/>
</div>
</div>
</div>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Loading...'} />
)}
{showReportPopUp && (
<ReportPopup handleClose={() => setShowReportPopUp(false)} />
)}
</>
)}
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,233 +0,0 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19 } from 'nostr-tools'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes } from 'routes'
import { store } from 'store'
import {
BlogPageLoaderResult,
FilterOptions,
ModeratedFilter,
NSFWFilter
} from 'types'
import {
DEFAULT_FILTER_OPTIONS,
getLocalStorageItem,
log,
LogType
} from 'utils'
import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog'
export const blogRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params, request }: LoaderFunctionArgs) => {
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs)
}
// Decode author and identifier from naddr
let pubkey: string | undefined
let identifier: string | undefined
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
pubkey = decoded.data.pubkey
identifier = decoded.data.identifier
} catch (error) {
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error)
throw new Error('Failed to fetch the blog. The address might be wrong')
}
const userState = store.getState().user
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
// Check if editing and the user is the original author
// Redirect if NOT
const url = new URL(request.url)
const isEditMode = url.pathname.endsWith('/edit')
if (isEditMode && loggedInUserPubkey !== pubkey) {
return redirect(appRoutes.blogs)
}
try {
// Set the filter for the main blog content
const filter = {
kinds: [kinds.LongFormArticle],
authors: [pubkey],
'#d': [identifier]
}
// Get the blog filter options for latest blogs
const filterOptions = JSON.parse(
getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS)
) as FilterOptions
// Fetch more in case the current blog is included in the latest and filters remove some
const latestFilter: NDKFilter = {
authors: [pubkey],
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Add source filter
if (filterOptions.source === window.location.host) {
latestFilter['#r'] = [filterOptions.source]
}
// Filter by NSFW tag
// NSFWFilter.Only_NSFW -> fetch with content-warning label
// NSFWFilter.Show_NSFW -> filter not needed
// NSFWFilter.Hide_NSFW -> up the limit and filter after fetch
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
// Parallel fetch blog event, latest events, mute, and nsfw lists
const settled = await Promise.allSettled([
ndkContext.fetchEvent(filter),
ndkContext.fetchEvents(latestFilter),
ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
ndkContext.getNSFWList()
])
const result: BlogPageLoaderResult = {
blog: undefined,
latest: [],
isAddedToNSFW: false,
isBlocked: false
}
// Check the blog event result
const fetchEventResult = settled[0]
if (fetchEventResult.status === 'fulfilled' && fetchEventResult.value) {
// Extract the blog details from the event
result.blog = extractBlogDetails(fetchEventResult.value)
} else if (fetchEventResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the blog event.',
fetchEventResult.reason
)
}
// Throw an error if we are missing the main blog result
// Handle it with the react-router's errorComponent
if (!result.blog) {
throw new Error('We are unable to find the blog on the relays')
}
// Check the lateast blog events
const fetchEventsResult = settled[1]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
result.latest = fetchEventsResult.value
.map(extractBlogCardDetails)
.filter((b) => b.id !== result.blog?.id) // Filter out current blog if present
} else if (fetchEventsResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the latest blog events.',
fetchEventsResult.reason
)
}
const muteLists = settled[2]
if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteLists && muteLists.value) {
if (result.blog && result.blog.aTag) {
if (
muteLists.value.admin.replaceableEvents.includes(
result.blog.aTag
) ||
muteLists.value.user.replaceableEvents.includes(result.blog.aTag)
) {
result.isBlocked = true
}
}
// 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 =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// 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)) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.admin.authors.includes(b.author!) &&
!muteLists.value.admin.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.user.authors.includes(b.author!) &&
!muteLists.value.user.replaceableEvents.includes(b.aTag!)
)
}
}
} else if (muteLists.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason)
}
const nsfwList = settled[3]
if (nsfwList.status === 'fulfilled' && nsfwList.value) {
// Check if the blog is marked as NSFW
// Mark it as NSFW only if it's missing the tag
if (result.blog) {
const isMissingNsfwTag =
!result.blog.nsfw &&
result.blog.aTag &&
nsfwList.value.includes(result.blog.aTag)
if (isMissingNsfwTag) {
result.blog.nsfw = true
}
if (result.blog.aTag && nsfwList.value.includes(result.blog.aTag)) {
result.isAddedToNSFW = true
}
}
// Check the latest blogs too
result.latest = result.latest.map((b) => {
// Add nsfw tag if it's missing
const isMissingNsfwTag =
!b.nsfw && b.aTag && nsfwList.value.includes(b.aTag)
if (isMissingNsfwTag) {
b.nsfw = true
}
return b
})
} else if (nsfwList.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason)
}
// Filter latest, sort and take only three
result.latest = result.latest
.filter(
// Filter out the NSFW if selected
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
.slice(0, 3)
return result
} catch (error) {
let message = 'An error occurred in fetching blog details from relays'
log(true, LogType.Error, message, error)
if (error instanceof Error) {
message = error.message
throw new Error(message)
}
}
}

View File

@ -1,92 +0,0 @@
import { useFetcher } from 'react-router-dom'
import { CheckboxFieldUncontrolled } from 'components/Inputs'
import { useEffect } from 'react'
type ReportPopupProps = {
handleClose: () => void
}
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
{ label: 'Spam', key: 'spam' },
{ label: 'Scam', key: 'scam' },
{ label: 'Malware', key: 'malware' },
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
{ label: 'Other', key: 'otherReason' }
]
export const ReportPopup = ({ handleClose }: ReportPopupProps) => {
const fetcher = useFetcher()
// Close automatically if action succeeds
useEffect(() => {
if (fetcher.data) {
const { isSent } = fetcher.data
console.log(fetcher.data)
if (isSent) {
handleClose()
}
}
}, [fetcher, handleClose])
return (
<>
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</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'>
<fetcher.Form
className='pUMCB_ZapsInside'
method='post'
action='report'
>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting this?
</label>
{BLOG_REPORT_REASONS.map((r) => (
<CheckboxFieldUncontrolled
key={r.key}
label={r.label}
name={r.key}
defaultChecked={false}
/>
))}
</div>
<button
className='btn btnMain pUMCB_Report'
type='submit'
style={{ width: '100%' }}
>
Submit Report
</button>
</fetcher.Form>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -1,146 +0,0 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { ActionFunctionArgs } from 'react-router-dom'
import { toast } from 'react-toastify'
import { store } from 'store'
import { UserRelaysType } from 'types'
import {
log,
LogType,
now,
npubToHex,
parseFormData,
sendDMUsingRandomKey,
signAndPublish
} from 'utils'
export const blogReportRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
const requestData = await request.formData()
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return false
}
// Decode author from naddr
let aTag: string | undefined
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
aTag = `${kind}:${pubkey}:${identifier}`
} catch (error) {
log(true, LogType.Error, 'Failed to decode naddr')
return false
}
if (!aTag) {
log(true, LogType.Error, 'Missing #a Tag')
return false
}
const userState = store.getState().user
let hexPubkey: string | undefined
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
}
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const reportingPubkey = npubToHex(reportingNpub)
// Parse the the data
const formSubmit = parseFormData(requestData)
const selectedOptionsCount = Object.values(formSubmit).filter(
(checked) => checked === 'on'
).length
if (selectedOptionsCount === 0) {
toast.error('At least one option should be checked!')
return false
}
if (reportingPubkey === hexPubkey) {
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [kinds.Mutelist],
authors: [hexPubkey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
filter,
hexPubkey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
if (alreadyExists) {
toast.warn(`Blog reference is already in user's mute list`)
return false
}
tags.push(['a', aTag])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: kinds.Mutelist,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: hexPubkey,
kind: kinds.Mutelist,
content: '',
created_at: now(),
tags: [['a', aTag]]
}
}
try {
hexPubkey = await window.nostr?.getPublicKey()
} catch (error) {
log(
true,
LogType.Error,
'Could not get pubkey for reporting blog!',
error
)
toast.error('Could not get pubkey for reporting blog!')
return false
}
const isUpdated = await signAndPublish(
unsignedEvent,
ndkContext.ndk,
ndkContext.publish
)
return { isSent: isUpdated }
} else {
const href = window.location.href
let message = `I'd like to report ${href} due to following reasons:\n`
Object.entries(formSubmit).forEach(([key, value]) => {
if (value === 'on') {
message += `* ${key}\n`
}
})
try {
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
ndkContext.ndk,
ndkContext.publish
)
return { isSent: isSent }
} catch (error) {
log(true, LogType.Error, 'Failed to send a blog report', error)
return false
}
}
}

144
src/pages/blogs.tsx Normal file
View File

@ -0,0 +1,144 @@
import { BlogCard } from '../components/BlogCard'
import '../styles/filters.css'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
export const BlogsPage = () => {
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs (WIP)</h2>
</div>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input type='text' className='SMIWInput' />
<button className='btn btnMain SMIWButton' type='button'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain'>
<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'
>
Latest
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Latest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Oldest
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Best Rated
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Worst Rated
</a>
</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'
>
Hide NSFW
</button>
<div className='dropdown-menu dropdownMainMenu'>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Hide NSFW
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Show NSFW
<br />
</a>
<a className='dropdown-item dropdownMainMenuItem' href='#'>
Only show NSFW
</a>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
</div>
<div className='IBMSecMain'>
<div className='PaginationMain'>
<div className='PaginationMainInside'>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-left'></i>
</a>
<div className='PaginationMainInsideBoxGroup'>
<a className='PaginationMainInsideBox PMIBActive' href='#'>
<p>1</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>2</p>{' '}
</a>
<a className='PaginationMainInsideBox' href='#'>
<p>3</p>
</a>
<p className='PaginationMainInsideBox PMIBDots'>...</p>
<a className='PaginationMainInsideBox' href='#'>
<p>8</p>
</a>
</div>
<a
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
href='#'
>
<i className='fas fa-chevron-right'></i>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,208 +0,0 @@
import { useMemo, useRef, useState } from 'react'
import { useLoaderData, useNavigation, useSearchParams } from 'react-router-dom'
import { useLocalStorage } from 'hooks'
import { BlogCardDetails, NSFWFilter, SortBy } from 'types'
import { SearchInput } from '../../components/SearchInput'
import { BlogCard } from '../../components/BlogCard'
import '../../styles/filters.css'
import '../../styles/pagination.css'
import '../../styles/search.css'
import '../../styles/styles.css'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner'
export const BlogsPage = () => {
const navigation = useNavigation()
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
const [filterOptions, setFilterOptions] = useLocalStorage(
'filter-blog-curated',
{
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
}
)
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
// Filter
const filteredBlogs = useMemo(() => {
const filterNsfwFn = (blog: Partial<BlogCardDetails>) => {
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
return !blog.nsfw
case NSFWFilter.Only_NSFW:
return blog.nsfw
default:
return blog
}
}
let filtered = blogs?.filter(filterNsfwFn) || []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
if (searchTerm !== '') {
const filterSearchTermFn = (blog: Partial<BlogCardDetails>) =>
(blog.title || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.summary || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.content || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.tTags || []).findIndex((tag) =>
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
filtered = filtered.filter(filterSearchTermFn)
}
if (filterOptions.sort === SortBy.Latest) {
filtered.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
} else if (filterOptions.sort === SortBy.Oldest) {
filtered.sort((a, b) =>
a.published_at && b.published_at ? a.published_at - b.published_at : 0
)
}
return filtered
}, [blogs, searchTerm, filterOptions.sort, filterOptions.nsfw])
// Pagination logic
const [currentPage, setCurrentPage] = useState(1)
const scrollTargetRef = useRef<HTMLDivElement>(null)
const MAX_BLOGS_PER_PAGE = 16
const totalBlogs = filteredBlogs.length
const totalPages = Math.ceil(totalBlogs / MAX_BLOGS_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_BLOGS_PER_PAGE
const endIndex = startIndex + MAX_BLOGS_PER_PAGE
const currentMods = filteredBlogs.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
scrollIntoView(scrollTargetRef.current)
setCurrentPage(page)
}
}
return (
<div className='InnerBodyMain'>
{navigation.state !== 'idle' && <LoadingSpinner desc={'Loading'} />}
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blogs</h2>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
</div>
</div>
<div className='IBMSecMain'>
<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(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
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.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods &&
currentMods.map((b) => <BlogCard key={b.id} {...b} />)}
</div>
</div>
{totalPages > 1 && (
<PaginationWithPageNumbers
currentPage={currentPage}
totalPages={totalPages}
handlePageChange={handlePageChange}
/>
)}
</div>
</div>
</div>
)
}

View File

@ -1,68 +0,0 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds } from 'nostr-tools'
import { BlogCardDetails } from 'types'
import { log, LogType, npubToHex } from 'utils'
import { extractBlogCardDetails } from 'utils/blog'
export const blogsRouteLoader = (ndkContext: NDKContextType) => async () => {
try {
const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',')
const blogHexkeys = blogNpubs
.map(npubToHex)
.filter((hexkey) => hexkey !== null)
const filter: NDKFilter = {
authors: blogHexkeys,
kinds: [kinds.LongFormArticle]
}
const settled = await Promise.allSettled([
ndkContext.fetchEvents(filter),
ndkContext.getMuteLists()
])
let blogs: Partial<BlogCardDetails>[] = []
const fetchEventsResult = settled[0]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
// Extract the blog card details from the events
blogs = fetchEventsResult.value
.map(extractBlogCardDetails)
.filter((b) => b.naddr)
} else if (fetchEventsResult.status === 'rejected') {
log(
true,
LogType.Error,
'Unable to fetch the blog events.',
fetchEventsResult.reason
)
return []
}
const muteListResult = settled[1]
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
// Filter out the blocked events
blogs = blogs.filter(
(b) =>
b.aTag &&
!muteListResult.value.admin.replaceableEvents.includes(b.aTag)
)
} else if (muteListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelists.',
muteListResult.reason
)
}
return blogs
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return []
}
}

View File

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

View File

@ -6,98 +6,46 @@ import {
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { SearchInput } from 'components/SearchInput'
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails } from 'types'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
DEFAULT_FILTER_OPTIONS,
extractModData,
isModDataComplete,
scrollIntoView
} from 'utils'
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy
} from 'types'
import { extractModData, isModDataComplete } from 'utils'
export const GamePage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const params = useParams()
const { name: gameName } = params
const { ndk } = useNDKContext()
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [mods, setMods] = useState<ModDetails[]>([])
const [currentPage, setCurrentPage] = useState(1)
const userState = useAppSelector((state) => state.user)
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
}
// Handle "Enter" key press inside the input
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
}
}
const filteredMods = useMemo(() => {
const filterSourceFn = (mod: ModDetails) => {
if (filterOptions.source === window.location.host) {
return mod.rTag === filterOptions.source
}
return true
}
// If search term is missing, only filter by sources
if (searchTerm === '') return mods.filter(filterSourceFn)
const lowerCaseSearchTerm = searchTerm.toLowerCase()
const filterFn = (mod: ModDetails) =>
mod.title.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.game.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.summary.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.body.toLowerCase().includes(lowerCaseSearchTerm) ||
mod.tags.findIndex((tag) =>
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
const filteredModList = useFilteredMods(
filteredMods,
const filteredMods = useFilteredMods(
mods,
userState,
filterOptions,
nsfwList,
@ -105,15 +53,14 @@ export const GamePage = () => {
)
// Pagination logic
const totalGames = filteredModList.length
const totalGames = filteredMods.length
const totalPages = Math.ceil(totalGames / MAX_MODS_PER_PAGE)
const startIndex = (currentPage - 1) * MAX_MODS_PER_PAGE
const endIndex = startIndex + MAX_MODS_PER_PAGE
const currentMods = filteredModList.slice(startIndex, endIndex)
const currentMods = filteredMods.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
scrollIntoView(scrollTargetRef.current)
setCurrentPage(page)
}
}
@ -155,10 +102,7 @@ export const GamePage = () => {
<>
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
@ -167,24 +111,14 @@ export const GamePage = () => {
<span className='IBMSMTitleMainHeadingSpan'>
{gameName}
</span>
{searchTerm !== '' && (
<>
&nbsp;&mdash;&nbsp;
<span className='IBMSMTitleMainHeadingSpan'>
{searchTerm}
</span>
</>
)}
</h2>
</div>
<SearchInput
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
ref={searchTermRef}
/>
</div>
</div>
<ModFilter />
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods.map((mod) => (

View File

@ -8,11 +8,8 @@ import '../styles/search.css'
import '../styles/styles.css'
import { createSearchParams, useNavigate } from 'react-router-dom'
import { appRoutes } from 'routes'
import { scrollIntoView } from 'utils'
import { SearchInput } from 'components/SearchInput'
export const GamesPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const { fetchMods } = useNDKContext()
const searchTermRef = useRef<HTMLInputElement>(null)
@ -66,7 +63,6 @@ export const GamesPage = () => {
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
scrollIntoView(scrollTargetRef.current)
setCurrentPage(page)
}
}
@ -75,8 +71,8 @@ export const GamesPage = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
if (value !== '') {
const searchParams = createSearchParams({
q: value,
kind: 'Games'
searchTerm: value,
searching: 'Games'
})
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
}
@ -92,20 +88,40 @@ export const GamesPage = () => {
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Games</h2>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div className='IBMSecMain IBMSMListWrapper'>

View File

@ -1,30 +1,22 @@
import { kinds, nip19 } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { Link, useNavigate, useNavigation } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
import { BlogCard } from '../components/BlogCard'
import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants'
import { LANDING_PAGE_DATA } from '../constants'
import {
useDidMount,
useGames,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types'
import {
extractBlogCardDetails,
extractModData,
handleModImageError,
log,
LogType,
npubToHex
} from '../utils'
import { ModDetails } from '../types'
import { extractModData, handleModImageError, log, LogType } from '../utils'
import '../styles/cardLists.css'
import '../styles/SimpleSlider.css'
@ -35,8 +27,7 @@ import { NDKFilter } from '@nostr-dev-kit/ndk'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { Spinner } from 'components/Spinner'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
export const HomePage = () => {
const navigate = useNavigate()
@ -123,7 +114,27 @@ export const HomePage = () => {
</div>
</div>
<DisplayLatestMods />
<DisplayLatestBlogs />
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
</div>
<div className='IBMSMList'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
<div className='IBMSMAction'>
<a
className='btn btnMain IBMSMActionBtn'
role='button'
href='blog.html'
>
View All
</a>
</div>
</div>
</div>
</div>
</div>
@ -141,7 +152,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => {
useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
const { identifier, kind, pubkey, relays = [] } = decoded.data
const ndkFilter: NDKFilter = {
'#a': [identifier],
@ -149,7 +160,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => {
kinds: [kind]
}
fetchEvent(ndkFilter)
fetchEvent(ndkFilter, relays)
.then((ndkEvent) => {
if (ndkEvent) {
const extracted = extractModData(ndkEvent)
@ -214,7 +225,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
useDidMount(() => {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
const { identifier, kind, pubkey, relays = [] } = decoded.data
const ndkFilter: NDKFilter = {
'#a': [identifier],
@ -222,7 +233,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
kinds: [kind]
}
fetchEvent(ndkFilter)
fetchEvent(ndkFilter, relays)
.then((ndkEvent) => {
if (ndkEvent) {
const extracted = extractModData(ndkEvent)
@ -256,8 +267,6 @@ const DisplayLatestMods = () => {
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)
setLatestMods(mods)
})
.finally(() => {
@ -311,136 +320,10 @@ const DisplayLatestMods = () => {
)
}
const DisplayLatestBlogs = () => {
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog-curated', {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
})
const navigation = useNavigation()
useDidMount(() => {
const fetchBlogs = async () => {
try {
// Show maximum of 4 blog posts
// 2 should be featured and the most recent 2 from blog npubs
// Populate the filter from known naddr (constants.ts)
const filter: NDKFilter = {
kinds: [kinds.LongFormArticle],
authors: [],
'#d': []
}
for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) {
try {
const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i]
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { pubkey, identifier } = decoded.data
if (!filter.authors?.includes(pubkey)) {
filter.authors?.push(pubkey)
}
if (!filter.authors?.includes(identifier)) {
filter['#d']?.push(identifier)
}
} catch (error) {
// Silently ignore
}
}
// Prepare filter for the latest
const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',')
const blogHexkeys = blogNpubs
.map(npubToHex)
.filter((hexkey) => hexkey !== null)
// We fetch more posts in case of duplicates (from featured)
const latestFilter: NDKFilter = {
authors: blogHexkeys,
kinds: [kinds.LongFormArticle],
limit: PROFILE_BLOG_FILTER_LIMIT
}
// Filter by NSFW tag
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
latestFilter['#L'] = ['content-warning']
}
const results = await Promise.allSettled([
fetchEvents(filter),
fetchEvents(latestFilter)
])
const events: Partial<BlogCardDetails>[] = []
// Get featured blogs posts result
results.forEach((r) => {
// Add events from both promises to the array
if (r.status === 'fulfilled' && r.value) {
events.push(
...r.value
.map(extractBlogCardDetails) // Extract the blog card details
.sort(
// Sort each result by published_at in descending order
// We can't sort everything at once we'd lose prefered
(a, b) =>
a.published_at && b.published_at
? b.published_at - a.published_at
: 0
)
)
}
})
// Remove duplicates
const unique = Array.from(
events
.filter((b) => b.id)
.reduce((map, obj) => {
map.set(obj.id!, obj)
return map
}, new Map<string, Partial<BlogCardDetails>>())
.values()
).filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
const latest = unique.slice(0, 4)
setBlogs(latest)
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return null
}
}
fetchBlogs()
})
const Spinner = () => {
return (
<div className='IBMSecMain IBMSMListWrapper'>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Fetching blog...'} />
)}
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts</h2>
</div>
<div className='IBMSMList'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
<div className='IBMSMAction'>
<Link
className='btn btnMain IBMSMActionBtn'
role='button'
to={appRoutes.blogs}
>
View All
</Link>
</div>
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
}

View File

@ -2,21 +2,17 @@ import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import Link from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { formatDate } from 'date-fns'
import FsLightbox from 'fslightbox-react'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { nip19, UnsignedEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { Link as ReactRouterLink, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { BlogCard } from '../../components/BlogCard'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ProfileSection } from '../../components/ProfileSection'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext,
useNSFWList
} from '../../hooks'
import { MetadataController, UserRelaysType } from '../../controllers'
import { useAppSelector, useDidMount, useNDKContext } from '../../hooks'
import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
import '../../styles/comments.css'
import '../../styles/downloads.css'
@ -28,13 +24,9 @@ import '../../styles/styles.css'
import '../../styles/tabs.css'
import '../../styles/tags.css'
import '../../styles/write.css'
import { DownloadUrl, ModDetails } from '../../types'
import {
BlogCardDetails,
DownloadUrl,
ModDetails,
UserRelaysType
} from '../../types'
import {
abbreviateNumber,
copyTextToClipboard,
downloadFile,
extractModData,
@ -46,31 +38,22 @@ import {
sendDMUsingRandomKey,
signAndPublish
} from '../../utils'
import { Comments } from '../../components/comment'
import { CheckboxField } from 'components/Inputs'
import { PublishDetails } from 'components/Internal/PublishDetails'
import { Interactions } from 'components/Internal/Interactions'
import { extractBlogCardDetails } from 'utils/blog'
import { Comments } from './internal/comment'
import { Reactions } from './internal/reactions'
import { Zap } from './internal/zap'
import placeholder from '../../assets/img/DEGMods Placeholder Img.png'
export const ModPage = () => {
const { naddr } = useParams()
const { fetchEvent } = useNDKContext()
const [mod, setMod] = useState<ModDetails>()
const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(true)
const [commentCount, setCommentCount] = useState(0)
// Make sure to mark non-nsfw mods as NSFW if found in nsfwList
const nsfwList = useNSFWList()
const isMissingNsfwTag =
!mod?.nsfw && mod?.aTag && nsfwList && nsfwList.includes(mod.aTag)
const modData = isMissingNsfwTag
? ({ ...mod, nsfw: true } as ModDetails)
: mod
useDidMount(async () => {
if (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: NDKFilter = {
'#a': [identifier],
@ -78,11 +61,11 @@ export const ModPage = () => {
kinds: [kind]
}
fetchEvent(filter)
fetchEvent(filter, relays)
.then((event) => {
if (event) {
const extracted = extractModData(event)
setMod(extracted)
setModData(extracted)
}
})
.catch((err) => {
@ -146,7 +129,7 @@ export const ModPage = () => {
nsfw={modData.nsfw}
/>
<Interactions
addressable={modData}
modDetails={modData}
commentCount={commentCount}
/>
<PublishDetails
@ -194,10 +177,21 @@ export const ModPage = () => {
)}
</div>
</div>
<DisplayModAuthorBlogs />
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>
Creator's Blog Posts (WIP)
</h4>
<div className='IBMSMList IBMSMListAlt'>
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
<BlogCard backgroundLink={placeholder} />
</div>
</div>
</div>
<div className='IBMSMSplitMainBigSideSec'>
<Comments
addressable={modData}
modDetails={modData}
setCommentCount={setCommentCount}
/>
</div>
@ -218,7 +212,7 @@ type GameProps = {
}
const Game = ({ naddr, game, author, aTag }: GameProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const { fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [isLoading, setIsLoading] = useState(false)
@ -227,8 +221,6 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
const [isBlocked, setIsBlocked] = useState(false)
const [isAddedToNSFW, setIsAddedToNSFW] = useState(false)
useBodyScrollDisable(showReportPopUp)
useEffect(() => {
if (userState.auth && userState.user?.pubkey) {
const pubkey = userState.user.pubkey as string
@ -351,7 +343,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
const isUpdated = await signAndPublish(unsignedEvent)
if (isUpdated) {
setIsBlocked(true)
}
@ -392,7 +384,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
const isUpdated = await signAndPublish(unsignedEvent)
if (isUpdated) {
setIsBlocked(false)
}
@ -458,7 +450,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
setLoadingSpinnerDesc('Updating nsfw list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
const isUpdated = await signAndPublish(unsignedEvent)
if (isUpdated) {
setIsAddedToNSFW(true)
}
@ -499,7 +491,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
}
setLoadingSpinnerDesc('Updating nsfw list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
const isUpdated = await signAndPublish(unsignedEvent)
if (isUpdated) {
setIsAddedToNSFW(false)
}
@ -668,25 +660,18 @@ type ReportPopupProps = {
handleClose: () => void
}
const MOD_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
{ label: 'Spam', key: 'spam' },
{ label: 'Scam', key: 'scam' },
{ label: 'Not a game mod', key: 'notAGameMod' },
{ label: 'Stolen game mod', key: 'stolenGameMod' },
{ label: `Wasn't tagged NSFW`, key: 'wasntTaggedNSFW' },
{ label: 'Other reason', key: 'otherReason' }
]
const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const { fetchEventFromUserRelays } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState(
MOD_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
acc[cur.key] = false
return acc
}, {})
)
const [selectedOptions, setSelectedOptions] = useState({
actuallyCP: false,
spam: false,
scam: false,
notAGameMod: false,
stolenGameMod: false,
wasntTaggedNSFW: false,
otherReason: false
})
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -723,8 +708,8 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
return
}
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const reportingPubkey = npubToHex(reportingNpub)
const metadataController = await MetadataController.getInstance()
const reportingPubkey = npubToHex(metadataController.reportingNpub)
if (reportingPubkey === hexPubkey) {
setLoadingSpinnerDesc(`Finding user's mute list`)
@ -775,7 +760,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
const isUpdated = await signAndPublish(unsignedEvent)
if (isUpdated) handleClose()
} else {
const href = window.location.href
@ -788,12 +773,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
})
setLoadingSpinnerDesc('Sending report')
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
ndk,
publish
)
const isSent = await sendDMUsingRandomKey(message, reportingPubkey!)
if (isSent) handleClose()
}
setIsLoading(false)
@ -832,15 +812,86 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
>
Why are you reporting this?
</label>
{MOD_REPORT_REASONS.map((r) => (
<CheckboxField
key={r.key}
label={r.label}
name={r.key}
isChecked={selectedOptions[r.key]}
handleChange={() => handleCheckboxChange(r.key)}
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Actually CP
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.actuallyCP}
onChange={() => handleCheckboxChange('actuallyCP')}
/>
))}
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Spam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.spam}
onChange={() => handleCheckboxChange('spam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>Scam</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.scam}
onChange={() => handleCheckboxChange('scam')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Not a game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.notAGameMod}
onChange={() => handleCheckboxChange('notAGameMod')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Stolen game mod
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.stolenGameMod}
onChange={() => handleCheckboxChange('stolenGameMod')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Wasn't tagged NSFW
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.wasntTaggedNSFW}
onChange={() => handleCheckboxChange('wasntTaggedNSFW')}
/>
</div>
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt'>
<label className='form-label labelMain'>
Other reason
</label>
<input
type='checkbox'
className='CheckboxMain'
name='reportOption'
checked={selectedOptions.otherReason}
onChange={() => handleCheckboxChange('otherReason')}
/>
</div>
</div>
<button
className='btn btnMain pUMCB_Report'
@ -964,6 +1015,126 @@ const Body = ({
)
}
type InteractionsProps = {
modDetails: ModDetails
commentCount: number
}
const Interactions = ({ modDetails, commentCount }: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 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.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap modDetails={modDetails} />
<Reactions modDetails={modDetails} />
</div>
</div>
)
}
type PublishDetailsProps = {
published_at: number
edited_at: number
site: string
}
const PublishDetails = ({
published_at,
edited_at,
site
}: PublishDetailsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPost_PostDetails'>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Publish date'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
data-bs-toggle='tooltip'
data-bss-tooltip
aria-label='Publish date'
>
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(
(published_at !== -1 ? published_at : edited_at) * 1000,
'dd/MM/yyyy hh:mm:ss aa'
)}
</p>
</div>
<div
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement'
title='Last modified'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
</p>
</div>
<a
data-bs-toggle='tooltip'
data-bs-placement='left'
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
href='#'
title='Published on'
target='_blank'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSPost_PDElementIcon'
>
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
</svg>
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
</a>
</div>
</div>
)
}
const Download = ({
url,
hash,
@ -1221,49 +1392,3 @@ const Download = ({
</div>
)
}
const DisplayModAuthorBlogs = () => {
const { naddr } = useParams()
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext()
useDidMount(() => {
const fetchBlogs = async () => {
try {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { pubkey } = decoded.data
const latestBlogPosts = await fetchEvents({
authors: [pubkey],
kinds: [kinds.LongFormArticle],
limit: 3
})
setBlogs(latestBlogPosts.map(extractBlogCardDetails))
} catch (error) {
log(
true,
LogType.Error,
'An error occurred in fetching blog details from relays',
error
)
return null
}
}
fetchBlogs()
})
if (!blogs?.length) return null
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSSPostsWrapper'>
<h4 className='IBMSMSMBSSPostsTitle'>Creator's Blog Posts</h4>
<div className='IBMSMList IBMSMListAlt'>
{blogs?.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
</div>
</div>
)
}

View File

@ -1,14 +1,11 @@
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'
MetadataController,
RelayController,
UserRelaysType
} from 'controllers'
import { formatDate } from 'date-fns'
import { useAppSelector, useDidMount, useNDKContext, useReactions } from 'hooks'
import { useComments } from 'hooks/useComments'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
@ -22,9 +19,9 @@ import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getProfilePageRoute } from 'routes'
import {
Addressable,
CommentEvent,
CommentEventStatus,
ModDetails,
UserProfile
} from 'types/index.ts'
import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils'
@ -45,30 +42,17 @@ type FilterOptions = {
}
type Props = {
addressable: Addressable
modDetails: ModDetails
setCommentCount: Dispatch<SetStateAction<number>>
}
export const Comments = ({ addressable, setCommentCount }: Props) => {
const { ndk, publish } = useNDKContext()
const { commentEvents, setCommentEvents } = useComments(
addressable.author,
addressable.aTag
)
export const Comments = ({ modDetails, setCommentCount }: Props) => {
const { commentEvents, setCommentEvents } = useComments(modDetails)
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
})
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(() => {
setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount])
@ -97,9 +81,8 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
kind: kinds.ShortTextNote,
created_at: now(),
tags: [
['e', addressable.id],
['a', addressable.aTag],
['p', addressable.author]
['e', modDetails.id],
['a', modDetails.aTag]
]
}
@ -122,52 +105,28 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
...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
}
}
const publish = async () => {
const metadataController = await MetadataController.getInstance()
const modAuthorReadRelays = await metadataController.findUserRelays(
modDetails.author,
UserRelaysType.Read
)
const commentatorWriteRelays = await metadataController.findUserRelays(
pubkey,
UserRelaysType.Write
)
return event
})
)
} else {
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
return {
...event,
status: CommentEventStatus.Published
}
}
const combinedRelays = [
...new Set(...modAuthorReadRelays, ...commentatorWriteRelays)
]
return event
})
)
}
const publishedOnRelays =
await RelayController.getInstance().publishOnRelays(
signedEvent,
combinedRelays
)
// 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)
if (publishedOnRelays.length === 0) {
setCommentEvents((prev) =>
prev.map((event) => {
if (event.id === signedEvent.id) {
@ -180,26 +139,45 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
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)
}
publish()
return true
}
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const comments = useMemo(() => {
let filteredComments = visible
let filteredComments = commentEvents
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.pubkey === addressable.author
(comment) => comment.pubkey === modDetails.author
)
}
@ -210,28 +188,13 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
}
return filteredComments
}, [visible, filterOptions.author, filterOptions.sort, addressable.author])
}, [commentEvents, filterOptions, modDetails.author])
const discoveredCount = commentEvents.length - visible.length
return (
<div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'>
{/* 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>
)}
</div>
<CommentForm handleSubmit={handleSubmit} />
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
@ -390,12 +353,12 @@ const Comment = (props: CommentEvent) => {
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
</a>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
{hexToNpub(props.pubkey)}
</Link>
</a>
</div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
@ -481,13 +444,15 @@ const Reactions = (props: Event) => {
eTag: props.id
})
if (!isDataLoaded) return null
return (
<>
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
onClick={() => handleReaction(true)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -499,9 +464,7 @@ const Reactions = (props: Event) => {
>
<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>
<p className='IBMSMSMBSSCL_CAElementText'>{likesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
@ -510,7 +473,7 @@ const Reactions = (props: Event) => {
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction() : undefined}
onClick={() => handleReaction()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -522,9 +485,7 @@ const Reactions = (props: Event) => {
>
<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>
<p className='IBMSMSMBSSCL_CAElementText'>{disLikesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
@ -535,21 +496,20 @@ const Reactions = (props: Event) => {
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)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
useDidMount(() => {
getTotalZapAmount(
props.pubkey,
props.id,
undefined,
userState.user?.pubkey as string
)
RelayController.getInstance()
.getTotalZapAmount(
props.pubkey,
props.id,
undefined,
userState.user?.pubkey as string
)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
setHasZapped(res.hasZapped)

View File

@ -1,12 +1,11 @@
import { Dots } from 'components/Spinner'
import { useReactions } from 'hooks'
import { Addressable } from 'types'
import { ModDetails } from 'types'
type ReactionsProps = {
addressable: Addressable
modDetails: ModDetails
}
export const Reactions = ({ addressable }: ReactionsProps) => {
export const Reactions = ({ modDetails }: ReactionsProps) => {
const {
isDataLoaded,
likesCount,
@ -15,18 +14,20 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
hasReactedPositively,
hasReactedNegatively
} = useReactions({
pubkey: addressable.author,
eTag: addressable.id,
aTag: addressable.aTag
pubkey: modDetails.author,
eTag: modDetails.id,
aTag: modDetails.aTag
})
if (!isDataLoaded) return null
return (
<>
<div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
onClick={() => handleReaction(true)}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -40,9 +41,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<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>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
@ -51,7 +50,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
}`}
onClick={isDataLoaded ? () => handleReaction() : undefined}
onClick={() => handleReaction()}
>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
@ -65,9 +64,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<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>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>

View File

@ -1,36 +1,31 @@
import { ZapSplit } from 'components/Zap'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext
} from 'hooks'
import { RelayController } from 'controllers'
import { useAppSelector, useDidMount } from 'hooks'
import { useState } from 'react'
import { toast } from 'react-toastify'
import { Addressable } from 'types'
import { ModDetails } from 'types'
import { abbreviateNumber } from 'utils'
type ZapProps = {
addressable: Addressable
modDetails: ModDetails
}
export const Zap = ({ addressable }: ZapProps) => {
export const Zap = ({ modDetails }: ZapProps) => {
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)
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
useDidMount(() => {
getTotalZapAmount(
addressable.author,
addressable.id,
addressable.aTag,
userState.user?.pubkey as string
)
RelayController.getInstance()
.getTotalZapAmount(
modDetails.author,
modDetails.id,
modDetails.aTag,
userState.user?.pubkey as string
)
.then((res) => {
setTotalZappedAmount(res.accumulatedZapAmount)
setHasZapped(res.hasZapped)
@ -70,9 +65,9 @@ export const Zap = ({ addressable }: ZapProps) => {
</div>
{isOpen && (
<ZapSplit
pubkey={addressable.author}
eventId={addressable.id}
aTag={addressable.aTag}
pubkey={modDetails.author}
eventId={modDetails.id}
aTag={modDetails.aTag}
setTotalZapAmount={setTotalZappedAmount}
setHasZapped={setHasZapped}
handleClose={() => setIsOpen(false)}

View File

@ -8,7 +8,6 @@ import { MOD_FILTER_LIMIT } from '../constants'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
@ -18,20 +17,24 @@ import '../styles/filters.css'
import '../styles/pagination.css'
import '../styles/search.css'
import '../styles/styles.css'
import { FilterOptions, ModDetails } from '../types'
import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils'
import { SearchInput } from 'components/SearchInput'
import {
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy
} from '../types'
export const ModsPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const { fetchMods } = useNDKContext()
const [isFetching, setIsFetching] = useState(false)
const [mods, setMods] = useState<ModDetails[]>([])
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
@ -63,7 +66,6 @@ export const ModsPage = () => {
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsFetching(false)
@ -82,7 +84,6 @@ export const ModsPage = () => {
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsFetching(false)
@ -102,12 +103,12 @@ export const ModsPage = () => {
{isFetching && <LoadingSpinner desc='Fetching mod details from relays' />}
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<PageTitleRow />
<ModFilter />
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
@ -138,8 +139,8 @@ const PageTitleRow = React.memo(() => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
if (value !== '') {
const searchParams = createSearchParams({
q: value,
kind: 'Mods'
searchTerm: value,
searching: 'Mods'
})
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
}
@ -158,11 +159,35 @@ const PageTitleRow = React.memo(() => {
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Mods</h2>
</div>
<SearchInput
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
/>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
)

View File

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

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

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

View File

@ -1,845 +0,0 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { Pagination } from 'components/Pagination'
import { ProfileSection } from 'components/ProfileSection'
import { Tabs } from 'components/Tabs'
import { MOD_FILTER_LIMIT, PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
import {
useAppSelector,
useFilteredMods,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import { kinds, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLoaderData, useNavigation } from 'react-router-dom'
import { toast } from 'react-toastify'
import { appRoutes } from 'routes'
import {
BlogCardDetails,
FilterOptions,
ModDetails,
ModeratedFilter,
NSFWFilter,
SortBy,
UserRelaysType
} from 'types'
import {
copyTextToClipboard,
DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails,
now,
npubToHex,
scrollIntoView,
sendDMUsingRandomKey,
signAndPublish
} from 'utils'
import { CheckboxField } from 'components/Inputs'
import { ProfilePageLoaderResult } from './loader'
import { BlogCard } from 'components/BlogCard'
export const ProfilePage = () => {
const {
profilePubkey,
profile,
isBlocked: _isBlocked,
isOwnProfile
} = useLoaderData() as ProfilePageLoaderResult
const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const [showReportPopUp, setShowReportPopUp] = useState(false)
const [isBlocked, setIsBlocked] = useState(_isBlocked)
const handleBlock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for updating mute list')
setIsLoading(false)
return
}
setLoadingSpinnerDesc(`Finding user's mute list`)
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === profilePubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
setIsBlocked(true)
return toast.warn(`User is already in the mute list`)
}
tags.push(['p', profilePubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', profilePubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(true)
}
setIsLoading(false)
}
const handleUnblock = async () => {
if (!profilePubkey) {
toast.error(`Something went wrong. Unable to find reported user's pubkey`)
return
}
const userHexKey = userState.user?.pubkey as string
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
setIsLoading(true)
setLoadingSpinnerDesc(`Finding user's mute list`)
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
if (!muteListEvent) {
toast.error(`Couldn't get user's mute list event from relays`)
return
}
const tags = muteListEvent.tags
const unsignedEvent: UnsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: tags.filter((item) => item[0] !== 'p' || item[1] !== profilePubkey)
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) {
setIsBlocked(false)
}
setIsLoading(false)
}
// Tabs
const [tab, setTab] = useState(0)
const [page, setPage] = useState(1)
// Mods
const [mods, setMods] = useState<ModDetails[]>([])
const filterKey = 'filter-profile'
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS
})
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const handleNext = useCallback(() => {
setIsLoading(true)
const until =
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
fetchMods({
source: filterOptions.source,
until,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev + 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
const handlePrev = useCallback(() => {
setIsLoading(true)
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
fetchMods({
source: filterOptions.source,
since,
author: profilePubkey
})
.then((res) => {
setMods(res)
setPage((prev) => prev - 1)
scrollIntoView(scrollTargetRef.current)
})
.finally(() => {
setIsLoading(false)
})
}, [mods, fetchMods, filterOptions.source, profilePubkey])
useEffect(() => {
setIsLoading(true)
switch (tab) {
case 0:
setLoadingSpinnerDesc('Fetching mods..')
fetchMods({ source: filterOptions.source, author: profilePubkey })
.then((res) => {
setMods(res)
})
.finally(() => {
setIsLoading(false)
})
break
default:
setIsLoading(false)
break
}
}, [filterOptions.source, tab, fetchMods, profilePubkey])
const filteredModList = useFilteredMods(
mods,
userState,
filterOptions,
nsfwList,
muteLists,
profilePubkey
)
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Profile'>
<div className='IBMSMSMBSSModFor'>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<p className='IBMSMSMBSSModForPara'>{displayName}'s Page</p>
<div
className='dropdown dropdownMain'
style={{ flexGrow: 'unset' }}
>
<button
className='btn btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
style={{
borderRadius: '5px',
background: 'unset',
padding: '5px'
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-192 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z'></path>
</svg>
</button>
<div className='dropdown-menu dropdown-menu-end dropdownMainMenu'>
{isOwnProfile && (
<Link
className='dropdown-item dropdownMainMenuItem'
to={appRoutes.settingsProfile}
>
<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='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
Edit
</Link>
)}
<a
className='dropdown-item dropdownMainMenuItem'
onClick={() => {
copyTextToClipboard(window.location.href).then(
(isCopied) => {
if (isCopied)
toast.success('Url copied to clipboard!')
}
)
}}
>
<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='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
Copy URL
</a>
<a
className='dropdown-item dropdownMainMenuItem'
href='#'
>
<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='M503.7 226.2l-176 151.1c-15.38 13.3-39.69 2.545-39.69-18.16V272.1C132.9 274.3 66.06 312.8 111.4 457.8c5.031 16.09-14.41 28.56-28.06 18.62C39.59 444.6 0 383.8 0 322.3c0-152.2 127.4-184.4 288-186.3V56.02c0-20.67 24.28-31.46 39.69-18.16l176 151.1C514.8 199.4 514.8 216.6 503.7 226.2z'></path>
</svg>
Share
</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'
>
<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}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M323.5 51.25C302.8 70.5 284 90.75 267.4 111.1C240.1 73.62 206.2 35.5 168 0C69.75 91.12 0 210 0 281.6C0 408.9 100.2 512 224 512s224-103.1 224-230.4C448 228.4 396 118.5 323.5 51.25zM304.1 391.9C282.4 407 255.8 416 226.9 416c-72.13 0-130.9-47.73-130.9-125.2c0-38.63 24.24-72.64 72.74-130.8c7 8 98.88 125.4 98.88 125.4l58.63-66.88c4.125 6.75 7.867 13.52 11.24 19.9C364.9 290.6 353.4 357.4 304.1 391.9z'></path>
</svg>
{isBlocked ? 'Unblock' : 'Block User'}
</a>
</>
)}
</div>
</div>
</div>
<Tabs
tabs={['Mods', 'Blogs', 'Posts']}
tab={tab}
setTab={setTab}
/>
{/* Tabs Content */}
{tab === 0 && (
<>
<ModFilter filterKey={filterKey} author={profilePubkey} />
<div className='IBMSMList IBMSMListAlt'>
{filteredModList.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
</div>
<Pagination
page={page}
disabledNext={mods.length < MOD_FILTER_LIMIT}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)}
{tab === 1 && <ProfileTabBlogs />}
{tab === 2 && <>WIP</>}
</div>
</div>
</div>
<ProfileSection pubkey={profilePubkey} />
</div>
</div>
{showReportPopUp && (
<ReportUserPopup
reportedPubkey={profilePubkey}
handleClose={() => setShowReportPopUp(false)}
/>
)}
</div>
</div>
)
}
type ReportUserPopupProps = {
reportedPubkey: string
handleClose: () => void
}
const USER_REPORT_REASONS = [
{ label: `User posts actual CP`, key: 'user_actuallyCP' },
{ label: `User is a spammer`, key: 'user_spam' },
{ label: `User is a scammer`, key: 'user_scam' },
{ label: `User posts malware`, key: 'user_malware' },
{ label: `User posts non-mods`, key: 'user_notAGameMod' },
{ label: `User doesn't tag NSFW`, key: 'user_wasntTaggedNSFW' },
{ label: `Other (user)`, key: 'user_otherReason' }
]
const ReportUserPopup = ({
reportedPubkey,
handleClose
}: ReportUserPopupProps) => {
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [selectedOptions, setSelectedOptions] = useState(
USER_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
acc[cur.key] = false
return acc
}, {})
)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const handleCheckboxChange = (option: keyof typeof selectedOptions) => {
setSelectedOptions((prevState) => ({
...prevState,
[option]: !prevState[option]
}))
}
const handleSubmit = async () => {
const selectedOptionsCount = Object.values(selectedOptions).filter(
(isSelected) => isSelected
).length
if (selectedOptionsCount === 0) {
toast.error('At least one option should be checked!')
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
let userHexKey: string
if (userState.auth && userState.user?.pubkey) {
userHexKey = userState.user.pubkey as string
} else {
userHexKey = (await window.nostr?.getPublicKey()) as string
}
if (!userHexKey) {
toast.error('Could not get pubkey for reporting user!')
setIsLoading(false)
return
}
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
const reportingPubkey = npubToHex(reportingNpub)
if (reportingPubkey === userHexKey) {
setLoadingSpinnerDesc(`Finding user's mute list`)
// Define the event filter to search for the user's mute list events.
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
const filter: NDKFilter = {
kinds: [NDKKind.MuteList],
authors: [userHexKey]
}
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
const muteListEvent = await fetchEventFromUserRelays(
filter,
userHexKey,
UserRelaysType.Write
)
let unsignedEvent: UnsignedEvent
if (muteListEvent) {
// get a list of tags
const tags = muteListEvent.tags
const alreadyExists =
tags.findIndex(
(item) => item[0] === 'p' && item[1] === reportedPubkey
) !== -1
if (alreadyExists) {
setIsLoading(false)
return toast.warn(
`Reporter user's pubkey is already in the mute list`
)
}
tags.push(['p', reportedPubkey])
unsignedEvent = {
pubkey: muteListEvent.pubkey,
kind: NDKKind.MuteList,
content: muteListEvent.content,
created_at: now(),
tags: [...tags]
}
} else {
unsignedEvent = {
pubkey: userHexKey,
kind: NDKKind.MuteList,
content: '',
created_at: now(),
tags: [['p', reportedPubkey]]
}
}
setLoadingSpinnerDesc('Updating mute list event')
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
if (isUpdated) handleClose()
} else {
const href = window.location.href
let message = `I'd like to report ${href} due to following reasons:\n`
Object.entries(selectedOptions).forEach(([key, value]) => {
if (value) {
message += `* ${key}\n`
}
})
setLoadingSpinnerDesc('Sending report')
const isSent = await sendDMUsingRandomKey(
message,
reportingPubkey!,
ndk,
publish
)
if (isSent) handleClose()
}
setIsLoading(false)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</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='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting the user?
</label>
{USER_REPORT_REASONS.map((r) => (
<CheckboxField
key={r.key}
label={r.label}
name={r.key}
isChecked={selectedOptions[r.key]}
handleChange={() => handleCheckboxChange(r.key)}
/>
))}
<button
className='btn btnMain pUMCB_Report'
type='button'
style={{ width: '100%' }}
onClick={handleSubmit}
>
Submit Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
const ProfileTabBlogs = () => {
const { profile, muteLists, nsfwList } =
useLoaderData() as ProfilePageLoaderResult
const navigation = useNavigation()
const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS)
const [isLoading, setIsLoading] = useState(true)
const blogfilter: NDKFilter = useMemo(() => {
const filter: NDKFilter = {
authors: [profile?.pubkey as string],
kinds: [kinds.LongFormArticle]
}
const host = window.location.host
if (filterOptions.source === host) {
filter['#r'] = [host]
}
if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
filter['#L'] = ['content-warning']
}
return filter
}, [filterOptions.nsfw, filterOptions.source, profile?.pubkey])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>([])
useEffect(() => {
if (profile) {
// Initial blog fetch, go beyond limit to check for next
const filter: NDKFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT + 1
}
fetchEvents(filter)
.then((events) => {
setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT)
})
.finally(() => {
setIsLoading(false)
})
}
}, [blogfilter, fetchEvents, profile])
const handleNext = useCallback(() => {
if (isLoading) return
const last = blogs.length > 0 ? blogs[blogs.length - 1] : undefined
if (last?.published_at) {
const until = last?.published_at - 1
const nextFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT + 1,
until
}
setIsLoading(true)
fetchEvents(nextFilter)
.then((events) => {
const nextBlogs = events.map(extractBlogCardDetails)
setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT)
setPage((prev) => prev + 1)
setBlogs(
nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr)
)
})
.finally(() => setIsLoading(false))
}
}, [blogfilter, blogs, fetchEvents, isLoading])
const handlePrev = useCallback(() => {
if (isLoading) return
const first = blogs.length > 0 ? blogs[0] : undefined
if (first?.published_at) {
const since = first.published_at + 1
const prevFilter = {
...blogfilter,
limit: PROFILE_BLOG_FILTER_LIMIT,
since
}
setIsLoading(true)
fetchEvents(prevFilter)
.then((events) => {
setHasMore(true)
setPage((prev) => prev - 1)
setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
})
.finally(() => setIsLoading(false))
}
}, [blogfilter, blogs, fetchEvents, isLoading])
const userState = useAppSelector((state) => state.user)
const moderatedAndSortedBlogs = useMemo(() => {
let _blogs = blogs || []
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === profile?.pubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Add nsfw tag to blogs included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
_blogs = _blogs.map((b) => {
return !b.nsfw && b.aTag && nsfwList.includes(b.aTag)
? { ...b, nsfw: true }
: b
})
}
// Filter nsfw (Hide_NSFW option)
_blogs = _blogs.filter(
(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)) {
_blogs = _blogs.filter(
(b) =>
!muteLists.admin.authors.includes(b.author!) &&
!muteLists.admin.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.moderated === ModeratedFilter.Moderated) {
_blogs = _blogs.filter(
(b) =>
!muteLists.user.authors.includes(b.author!) &&
!muteLists.user.replaceableEvents.includes(b.aTag!)
)
}
if (filterOptions.sort === SortBy.Latest) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
} else if (filterOptions.sort === SortBy.Oldest) {
_blogs.sort((a, b) =>
a.published_at && b.published_at ? a.published_at - b.published_at : 0
)
}
return _blogs
}, [
blogs,
filterOptions.moderated,
filterOptions.nsfw,
filterOptions.sort,
muteLists.admin.authors,
muteLists.admin.replaceableEvents,
muteLists.user.authors,
muteLists.user.replaceableEvents,
nsfwList,
profile?.pubkey,
userState.user?.npub,
userState.user?.pubkey
])
return (
<>
{(isLoading || navigation.state !== 'idle') && (
<LoadingSpinner desc={'Loading...'} />
)}
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />
<div className='IBMSMList IBMSMListAlt'>
{moderatedAndSortedBlogs.map((b) => (
<BlogCard key={b.id} {...b} />
))}
</div>
{!(page === 1 && !hasMore) && (
<Pagination
page={page}
disabledNext={!hasMore}
handlePrev={handlePrev}
handleNext={handleNext}
/>
)}
</>
)
}

View File

@ -1,147 +0,0 @@
import { NDKContextType } from 'contexts/NDKContext'
import { nip19 } from 'nostr-tools'
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import { store } from 'store'
import { MuteLists, UserProfile } from 'types'
import { log, LogType, npubToHex } from 'utils'
export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile
isBlocked: boolean
isOwnProfile: boolean
muteLists: {
admin: MuteLists
user: MuteLists
}
nsfwList: string[]
}
export const profileRouteLoader =
(ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => {
// Try to decode nprofile parameter
const { nprofile } = params
let profilePubkey: string | undefined
try {
// Decode if it starts with nprofile1
if (nprofile?.startsWith('nprofile1')) {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} else if (nprofile?.startsWith('npub1')) {
// Try to get hex from the npub and encode it to nprofile
const value = npubToHex(nprofile)
if (value) {
return redirect(
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: value
})
)
)
}
}
} catch (error) {
// Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error)
}
// 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
}
// Redirect if profile naddr is missing
// - home if user is not logged
let profileRoute = appRoutes.home
if (!profilePubkey && userPubkey) {
// - own profile
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userPubkey
})
)
}
if (!profilePubkey) return redirect(profileRoute)
// Empty result
const result: ProfilePageLoaderResult = {
profilePubkey: profilePubkey,
profile: {},
isBlocked: false,
isOwnProfile: false,
muteLists: {
admin: {
authors: [],
replaceableEvents: []
},
user: {
authors: [],
replaceableEvents: []
}
},
nsfwList: []
}
// 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.getNSFWList()
])
// Check the profile event result
const profileEventResult = settled[0]
if (profileEventResult.status === 'fulfilled' && profileEventResult.value) {
result.profile = profileEventResult.value
} else if (profileEventResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch profile.',
profileEventResult.reason
)
}
// Check the profile event result
const muteListResult = settled[1]
if (muteListResult.status === 'fulfilled' && muteListResult.value) {
result.muteLists = muteListResult.value
// Check if user has blocked this profile
result.isBlocked = result.muteLists.user.authors.includes(profilePubkey)
} else if (muteListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelist.',
muteListResult.reason
)
}
// Check the profile event result
const nsfwListResult = settled[2]
if (nsfwListResult.status === 'fulfilled' && nsfwListResult.value) {
result.nsfwList = nsfwListResult.value
} else if (nsfwListResult.status === 'rejected') {
log(
true,
LogType.Error,
'Failed to fetch mutelist.',
nsfwListResult.reason
)
}
return result
}

View File

@ -4,16 +4,15 @@ import {
NDKKind,
NDKSubscriptionCacheUsage,
NDKUserProfile,
NostrEvent,
profileFromEvent
} from '@nostr-dev-kit/ndk'
import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/ModsFilter'
import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection'
import { SearchInput } from 'components/SearchInput'
import {
MAX_GAMES_PER_PAGE,
MAX_MODS_PER_PAGE,
@ -23,20 +22,28 @@ import {
useAppSelector,
useFilteredMods,
useGames,
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
} from 'hooks'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
import {
DEFAULT_FILTER_OPTIONS,
extractModData,
isModDataComplete,
scrollIntoView
} from 'utils'
FilterOptions,
ModDetails,
ModeratedFilter,
MuteLists,
NSFWFilter,
SortBy
} from 'types'
import { extractModData, isModDataComplete, log, LogType } from 'utils'
enum SearchKindEnum {
Mods = 'Mods',
@ -45,36 +52,30 @@ enum SearchKindEnum {
}
export const SearchPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [searchParams] = useSearchParams()
const muteLists = useMuteLists()
const nsfwList = useNSFWList()
const searchTermRef = useRef<HTMLInputElement>(null)
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
const [searchKind, setSearchKind] = useState(
(searchParams.get('searching') as SearchKindEnum) || SearchKindEnum.Mods
)
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
})
const [searchTerm, setSearchTerm] = useState(
searchParams.get('searchTerm') || ''
)
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
}
// Handle "Enter" key press inside the input
@ -87,10 +88,7 @@ export const SearchPage = () => {
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div
className='IBMSecMainGroup IBMSecMainGroupAlt'
ref={scrollTargetRef}
>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
@ -101,21 +99,48 @@ export const SearchPage = () => {
</span>
</h2>
</div>
<SearchInput
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
ref={searchTermRef}
/>
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
<input
type='text'
className='SMIWInput'
ref={searchTermRef}
onKeyDown={handleKeyDown}
placeholder='Enter search term'
/>
<button
className='btn btnMain SMIWButton'
type='button'
onClick={handleSearch}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<Filters />
<Filters
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
searchKind={searchKind}
setSearchKind={setSearchKind}
/>
{searchKind === SearchKindEnum.Mods && (
<ModsResult
searchTerm={searchTerm}
filterOptions={filterOptions}
muteLists={muteLists}
nsfwList={nsfwList}
el={scrollTargetRef.current}
/>
)}
{searchKind === SearchKindEnum.Users && (
@ -134,29 +159,73 @@ export const SearchPage = () => {
)
}
const Filters = React.memo(() => {
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
type FiltersProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
searchKind: SearchKindEnum
setSearchKind: Dispatch<SetStateAction<SearchKindEnum>>
}
const userState = useAppSelector((state) => state.user)
const [searchParams, setSearchParams] = useSearchParams()
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
const handleChangeSearchKind = (kind: SearchKindEnum) => {
searchParams.set('kind', kind)
setSearchParams(searchParams, {
replace: true
})
}
const Filters = React.memo(
({
filterOptions,
setFilterOptions,
searchKind,
setSearchKind
}: FiltersProps) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{searchKind === SearchKindEnum.Mods && <ModFilter />}
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{searchKind === SearchKindEnum.Mods && (
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
)}
{searchKind === SearchKindEnum.Users && (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</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
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
)}
{searchKind === SearchKindEnum.Users && (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
@ -165,65 +234,26 @@ const Filters = React.memo(() => {
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
Searching: {searchKind}
</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
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
{Object.values(SearchKindEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() => setSearchKind(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'
>
Searching: {searchKind}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SearchKindEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() => handleChangeSearchKind(item)}
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
})
)
}
)
type ModsResultProps = {
filterOptions: FilterOptions
@ -233,15 +263,13 @@ type ModsResultProps = {
user: MuteLists
}
nsfwList: string[]
el: HTMLElement | null
}
const ModsResult = ({
filterOptions,
searchTerm,
muteLists,
nsfwList,
el
nsfwList
}: ModsResultProps) => {
const { ndk } = useNDKContext()
const [mods, setMods] = useState<ModDetails[]>([])
@ -277,13 +305,10 @@ const ModsResult = ({
}, [ndk])
useEffect(() => {
scrollIntoView(el)
setPage(1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm])
const filteredMods = useMemo(() => {
// Search page requires search term
if (searchTerm === '') return []
const lowerCaseSearchTerm = searchTerm.toLowerCase()
@ -297,16 +322,8 @@ const ModsResult = ({
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
const filterSourceFn = (mod: ModDetails) => {
// Filter by source if selected
if (filterOptions.source === window.location.host) {
return mod.rTag === filterOptions.source
}
return true
}
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
return mods.filter(filterFn)
}, [mods, searchTerm])
const filteredModList = useFilteredMods(
filteredMods,
@ -317,12 +334,10 @@ const ModsResult = ({
)
const handleNext = () => {
scrollIntoView(el)
setPage((prev) => prev + 1)
}
const handlePrev = () => {
scrollIntoView(el)
setPage((prev) => prev - 1)
}
@ -361,70 +376,39 @@ const UsersResult = ({
moderationFilter,
muteLists
}: UsersResultProps) => {
const { ndk } = useNDKContext()
const { fetchEvents } = useNDKContext()
const [isFetching, setIsFetching] = useState(false)
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (searchTerm === '') {
setProfiles([])
} else {
const sub = ndk.subscribe(
{
kinds: [NDKKind.Metadata],
search: searchTerm
},
{
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
},
undefined,
false
)
const filter: NDKFilter = {
kinds: [NDKKind.Metadata],
search: searchTerm
}
// Stop the sub after 10 seconds if we are still searching the same term as before
window.setTimeout(() => {
if (sub.filter.search === searchTerm) {
sub.stop()
}
}, 10000)
const onEvent = (event: NostrEvent | NDKEvent) => {
if (!(event instanceof NDKEvent)) event = new NDKEvent(undefined, event)
const dedupKey = event.deduplicationKey()
const existingEvent = events.get(dedupKey)
if (existingEvent) {
event = dedup(existingEvent, event)
}
event.ndk = this
events.set(dedupKey, event)
// We can't rely on the 'eose' to arrive
// Instead we repeat and sort results on each event
const ndkEvents = Array.from(events.values())
const profiles: NDKUserProfile[] = []
ndkEvents.forEach((event) => {
try {
const profile = profileFromEvent(event)
profiles.push(profile)
} catch (error) {
// If we are unable to parse silently skip over the errors
}
setIsFetching(true)
fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es'])
.then((events) => {
const results = events.map((event) => {
const ndkEvent = new NDKEvent(undefined, event)
const profile = profileFromEvent(ndkEvent)
return profile
})
setProfiles(results)
})
.catch((err) => {
log(true, LogType.Error, 'An error occurred in fetching users', err)
})
.finally(() => {
setIsFetching(false)
})
setProfiles(profiles)
}
// Clear previous results
const events = new Map<string, NDKEvent>()
// Bind handler and start the sub
sub.on('event', onEvent)
sub.start()
return () => {
sub.stop()
}
}
}, [ndk, searchTerm])
}, [searchTerm, fetchEvents])
const filteredProfiles = useMemo(() => {
let filtered = [...profiles]
@ -449,13 +433,25 @@ const UsersResult = ({
}, [userState.user?.npub, moderationFilter, profiles, muteLists])
return (
<>
{isFetching && <LoadingSpinner desc='Fetching Profiles' />}
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredProfiles.map((profile) => {
if (profile.pubkey) {
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
return (
<ErrorBoundary key={profile.pubkey}>
<Profile pubkey={profile.pubkey as string} />
<Profile
pubkey={profile.pubkey as string}
displayName={displayName}
about={about}
image={profile?.image}
nip05={profile?.nip05}
lud16={profile?.lud16}
/>
</ErrorBoundary>
)
}
@ -523,11 +519,3 @@ const GamesResult = ({ searchTerm }: GamesResultProps) => {
</>
)
}
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

@ -1,4 +1,5 @@
import { AdminSVG, PreferenceSVG, ProfileSVG, RelaySVG } from 'components/SVGs'
import { MetadataController } from 'controllers'
import { useAppSelector } from 'hooks'
import { logout } from 'nostr-login'
import { useEffect, useState } from 'react'
@ -56,12 +57,15 @@ const SettingTabs = () => {
const userState = useAppSelector((state) => state.user)
useEffect(() => {
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
if (userState.auth && userState.user?.npub) {
setIsAdmin(adminNpubs.includes(userState.user.npub as string))
} else {
setIsAdmin(false)
}
MetadataController.getInstance().then((controller) => {
if (userState.auth && userState.user?.npub) {
setIsAdmin(
controller.adminNpubs.includes(userState.user.npub as string)
)
} else {
setIsAdmin(false)
}
})
}, [userState])
const handleSignOut = () => {

View File

@ -1,6 +1,6 @@
import { InputField } from 'components/Inputs'
import { ProfileQRButtonWithPopUp } from 'components/ProfileSection'
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
import { useAppDispatch, useAppSelector } from 'hooks'
import { kinds, nip19, UnsignedEvent, Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
@ -14,6 +14,7 @@ import {
profileFromEvent,
serializeProfile
} from '@nostr-dev-kit/ndk'
import { RelayController } from 'controllers'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { setUser } from 'store/reducers/user'
import placeholderMod from '../../assets/img/DEGMods Placeholder Img.png'
@ -42,7 +43,6 @@ const defaultFormState: FormState = {
export const ProfileSettings = () => {
const dispatch = useAppDispatch()
const userState = useAppSelector((state) => state.user)
const { ndk, publish } = useNDKContext()
const [isPublishing, setIsPublishing] = useState(false)
const [formState, setFormState] = useState<FormState>(defaultFormState)
@ -98,15 +98,15 @@ export const ProfileSettings = () => {
// In case user is not logged in clicking on profile link will navigate to homepage
let profileRoute = appRoutes.home
let nprofile: string | undefined
if (userState.auth && userState.user) {
const hexPubkey = npubToHex(userState.user.npub as string)
if (hexPubkey) {
nprofile = nip19.nprofileEncode({
pubkey: hexPubkey
})
profileRoute = getProfilePageRoute(nprofile)
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: hexPubkey
})
)
}
}
@ -163,8 +163,9 @@ export const ProfileSettings = () => {
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
@ -247,8 +248,10 @@ export const ProfileSettings = () => {
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
{typeof userState.user?.pubkey === 'string' && (
<ProfileQRButtonWithPopUp
pubkey={userState.user.pubkey}
/>
)}
</div>
</div>

View File

@ -1,23 +1,21 @@
import {
getRelayListForUser,
NDKEvent,
NDKRelayList,
NDKRelayStatus
} from '@nostr-dev-kit/ndk'
import { NDKRelayList } from '@nostr-dev-kit/ndk'
import { InputField } from 'components/Inputs'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
import {
MetadataController,
RelayController,
UserRelaysType
} from 'controllers'
import { useAppSelector, useDidMount } from 'hooks'
import { Event, kinds, UnsignedEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { UserRelaysType } from 'types'
import { log, LogType, normalizeWebSocketURL, now } from 'utils'
const READ_MARKER = 'read'
const WRITE_MARKER = 'write'
export const RelaySettings = () => {
const { ndk, publish } = useNDKContext()
const userState = useAppSelector((state) => state.user)
const [ndkRelayList, setNDKRelayList] = useState<NDKRelayList | null>(null)
const [isPublishing, setIsPublishing] = useState(false)
@ -25,8 +23,10 @@ export const RelaySettings = () => {
const [inputValue, setInputValue] = useState('')
useEffect(() => {
if (userState.auth && userState.user?.pubkey) {
getRelayListForUser(userState.user.pubkey as string, ndk)
const fetchRelayList = async (pubkey: string) => {
const metadataController = await MetadataController.getInstance()
metadataController
.getNDKRelayList(pubkey)
.then((res) => {
setNDKRelayList(res)
})
@ -38,10 +38,14 @@ export const RelaySettings = () => {
)
setNDKRelayList(null)
})
}
if (userState.auth && userState.user?.pubkey) {
fetchRelayList(userState.user.pubkey as string)
} else {
setNDKRelayList(null)
}
}, [userState, ndk])
}, [userState])
const handleAdd = async (relayUrl: string) => {
if (!ndkRelayList) return
@ -74,8 +78,11 @@ export const RelaySettings = () => {
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays =
await RelayController.getInstance().publishOnRelays(
signedEvent,
ndkRelayList.writeRelayUrls
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
@ -133,8 +140,11 @@ export const RelaySettings = () => {
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays =
await RelayController.getInstance().publishOnRelays(
signedEvent,
ndkRelayList.writeRelayUrls
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
@ -204,8 +214,11 @@ export const RelaySettings = () => {
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays =
await RelayController.getInstance().publishOnRelays(
signedEvent,
ndkRelayList.writeRelayUrls
)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
@ -369,29 +382,17 @@ const RelayListItem = ({
changeRelayType
}: RelayItemProps) => {
const [isConnected, setIsConnected] = useState(false)
const { ndk } = useNDKContext()
useDidMount(() => {
const ndkPool = ndk.pool
ndkPool.on('relay:connect', (relay) => {
if (relay.url === relayUrl) {
setIsConnected(true)
}
})
ndkPool.on('relay:disconnect', (relay) => {
if (relay.url === relayUrl) {
setIsConnected(false)
}
})
const relay = ndkPool.relays.get(relayUrl)
if (relay && relay.status >= NDKRelayStatus.CONNECTED) {
setIsConnected(true)
} else {
setIsConnected(false)
}
RelayController.getInstance()
.connectRelay(relayUrl)
.then((relay) => {
if (relay && relay.connected) {
setIsConnected(true)
} else {
setIsConnected(false)
}
})
})
return (

View File

@ -29,7 +29,7 @@ export const SubmitModPage = () => {
useDidMount(async () => {
if (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
const { identifier, kind, pubkey, relays = [] } = decoded.data
const filter: NDKFilter = {
'#a': [identifier],
@ -39,7 +39,7 @@ export const SubmitModPage = () => {
setIsFetching(true)
fetchEvent(filter)
fetchEvent(filter, relays)
.then((event) => {
if (event) {
const extracted = extractModData(event)

74
src/pages/write.tsx Normal file
View File

@ -0,0 +1,74 @@
import { CheckboxField, InputField } from '../components/Inputs'
import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector } from '../hooks'
import '../styles/innerPage.css'
import '../styles/styles.css'
import '../styles/write.css'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Write a blog post (WIP)
</h2>
</div>
<div className='IBMSMSMBS_Write'>
<InputField
label='Title'
placeholder=''
name='title'
value=''
onChange={() => {}}
/>
<InputField
label='Body'
placeholder=''
name='body'
value=''
onChange={() => {}}
/>
<InputField
label='Featured Image URL'
placeholder=''
name='imageUrl'
inputMode='url'
value=''
onChange={() => {}}
/>
<InputField
label='Summary'
placeholder=''
name='summary'
type='textarea'
value=''
onChange={() => {}}
/>
<CheckboxField
label='This mod not safe for work (NSFW)'
name='nsfw'
isChecked={false}
handleChange={() => {}}
/>
<div className='IBMSMSMBS_WriteAction'>
<button className='btn btnMain' type='button'>
Publish
</button>
</div>
</div>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,189 +0,0 @@
import { NDKContextType } from 'contexts/NDKContext'
import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { getBlogPageRoute } from 'routes'
import { BlogFormErrors, BlogEventSubmitForm, BlogEventEditForm } from 'types'
import {
isReachable,
isValidImageUrl,
log,
LogType,
now,
parseFormData
} from 'utils'
import TurndownService from 'turndown'
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
import { toast } from 'react-toastify'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { v4 as uuidv4 } from 'uuid'
import { store } from 'store'
export const writeRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
// Get the current state
const userState = store.getState().user
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
try {
hexPubkey = (await window.nostr?.getPublicKey()) as string
} catch (error) {
if (error instanceof Error) {
log(true, LogType.Error, 'Failed to get public key.', error)
}
toast.error('Failed to get public key.')
return null
}
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
return null
}
// Get the form data from submit request
const formData = await request.formData()
// Parse the the data
const formSubmit = parseFormData<BlogEventSubmitForm | BlogEventEditForm>(
formData
)
// Check for errors
const formErrors = await validateFormData(formSubmit)
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Get the markdown from the html
const turndownService = new TurndownService()
const content = turndownService.turndown(formSubmit.content!)
// Check if we are editing or this is a new blog
const { naddr } = params
const isEditing =
naddr && request.method === 'PUT' && isEditForm(formSubmit)
const formEdit = isEditing ? formSubmit : undefined
const currentTimeStamp = now()
// Get the existing edited fields or new ones
const uuid = isEditing && formEdit?.dTag ? formSubmit.dTag : uuidv4()
const rTag =
isEditing && formEdit?.rTag ? formEdit.rTag : window.location.host
const published_at =
isEditing && formEdit?.published_at
? formEdit.published_at
: currentTimeStamp
const aTag = `${kinds.LongFormArticle}:${hexPubkey}:${uuid}`
const tTags = formSubmit
.tags!.toLowerCase()
.split(',')
.map((t) => ['t', t])
const tags = [
['d', uuid],
['a', aTag],
['r', rTag],
['published_at', published_at.toString()],
['title', formSubmit.title!],
['image', formSubmit.image!],
['summary', formSubmit.summary!],
...tTags
]
// 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'])
const unsignedEvent: UnsignedEvent = {
kind: kinds.LongFormArticle,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: content,
tags: tags
}
try {
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
if (!signedEvent) {
toast.error('Failed to sign the event!')
return null
}
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
const publishedOnRelays = await ndkContext.publish(ndkEvent)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay.')
return null
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
const naddr = nip19.naddrEncode({
identifier: uuid,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
return redirect(getBlogPageRoute(naddr))
}
} catch (error) {
log(true, LogType.Error, 'Failed to sign the event!', error)
toast.error('Failed to sign the event!')
return null
}
}
const validateFormData = async (
formData: Partial<BlogEventSubmitForm>
): Promise<BlogFormErrors> => {
const errors: BlogFormErrors = {}
if (!formData.title || formData.title.trim() === '') {
errors.title = 'Title field can not be empty'
}
if (
!formData.content ||
formData.content.trim() === '' ||
formData.content.trim() === '<p></p>'
) {
errors.content = 'Content field can not be empty'
}
if (!formData.summary || formData.summary.trim() === '') {
errors.summary = 'Summary field can not be empty'
}
if (!formData.tags || formData.tags.trim() === '') {
errors.tags = 'Tags field can not be empty'
}
if (!formData.image || formData.image.trim() === '') {
errors.image = 'Image url field can not be empty'
} else if (
!isValidImageUrl(formData.image) ||
!(await isReachable(formData.image))
) {
errors.image = 'Image must be a valid and reachable'
}
return errors
}
function isEditForm(
form: Partial<BlogEventSubmitForm | BlogEventEditForm>
): form is BlogEventEditForm {
return (form as BlogEventEditForm).dTag !== undefined
}

View File

@ -1,156 +0,0 @@
import { useState } from 'react'
import {
Form,
useActionData,
useLoaderData,
useNavigation
} from 'react-router-dom'
import {
CheckboxFieldUncontrolled,
InputError,
InputFieldUncontrolled,
MenuBar
} 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'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
const data = useLoaderData() as BlogPageLoaderResult
const formErrors = useActionData() as BlogFormErrors
const navigation = useNavigation()
const blog = data?.blog
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const [content, setContent] = useState<string>(sanitized)
const editor = useEditor({
content: content,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
onUpdate: ({ editor }) => {
setContent(editor.getHTML())
}
})
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
</div>
{navigation.state === 'loading' && (
<LoadingSpinner desc='Loading..' />
)}
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing blog to relays' />
)}
<Form className='IBMSMSMBS_Write' method={blog ? 'put' : 'post'}>
<InputFieldUncontrolled
label='Title'
name='title'
defaultValue={blog?.title}
error={formErrors?.title}
/>
{editor && (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
<input name='content' hidden value={content} readOnly />
</div>
)}
<InputFieldUncontrolled
label='Featured Image URL'
name='image'
inputMode='url'
defaultValue={blog?.image}
error={formErrors?.image}
/>
<InputFieldUncontrolled
label='Summary'
name='summary'
type='textarea'
defaultValue={blog?.summary}
error={formErrors?.summary}
/>
<InputFieldUncontrolled
label='Tags'
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
placeholder='Tags'
name='tags'
defaultValue={blog?.tTags?.join(', ')}
error={formErrors?.tags}
/>
<CheckboxFieldUncontrolled
label='This post is not safe for work (NSFW)'
name='nsfw'
defaultChecked={blog?.nsfw}
/>
{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'
type='submit'
disabled={
navigation.state === 'loading' ||
navigation.state === 'submitting'
}
>
{navigation.state === 'submitting'
? 'Publishing...'
: 'Publish'}
</button>
</div>
</Form>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,42 +1,25 @@
import { createBrowserRouter } from 'react-router-dom'
import { NDKContextType } from 'contexts/NDKContext'
import { Layout } from 'layout'
import { SearchPage } from '../pages/search'
import { SearchPage } from 'pages/search'
import { AboutPage } from '../pages/about'
import { BlogsPage } from '../pages/blogs'
import { GamesPage } from '../pages/games'
import { HomePage } from '../pages/home'
import { ModPage } from '../pages/mod'
import { ModsPage } from '../pages/mods'
import { ProfilePage } from '../pages/profile'
import { profileRouteLoader } from 'pages/profile/loader'
import { SettingsPage } from '../pages/settings'
import { SubmitModPage } from '../pages/submitMod'
import { GamePage } from '../pages/game'
import { NotFoundPage } from '../pages/404'
import { FeedLayout } from '../layout/feed'
import { FeedPage } from '../pages/feed'
import { NotificationsPage } from '../pages/notifications'
import { WritePage } from '../pages/write'
import { writeRouteAction } from '../pages/write/action'
import { BlogsPage } from 'pages/blogs'
import { blogsRouteLoader } from 'pages/blogs/loader'
import { BlogPage } from 'pages/blog'
import { blogRouteLoader } from 'pages/blog/loader'
import { blogRouteAction } from 'pages/blog/action'
import { blogReportRouteAction } from 'pages/blog/reportAction'
import { GamePage } from 'pages/game'
export const appRoutes = {
index: '/',
home: '/',
home: '/home',
games: '/games',
game: '/game/:name',
mods: '/mods',
mod: '/mod/:naddr',
about: '/about',
blogs: '/blog',
blog: '/blog/:naddr',
blogEdit: '/blog/:naddr/edit',
blogReport_actionOnly: '/blog/:naddr/report',
blog: '/blog',
submitMod: '/submit-mod',
editMod: '/edit-mod/:naddr',
write: '/write',
@ -45,9 +28,7 @@ export const appRoutes = {
settingsRelays: '/settings-relays',
settingsPreferences: '/settings-preferences',
settingsAdmin: '/settings-admin',
profile: '/profile/:nprofile?',
feed: '/feed',
notifications: '/notifications'
profile: '/profile/:nprofile'
}
export const getGamePageRoute = (name: string) =>
@ -59,119 +40,76 @@ export const getModPageRoute = (eventId: string) =>
export const getModsEditPageRoute = (eventId: string) =>
appRoutes.editMod.replace(':naddr', eventId)
export const getBlogPageRoute = (eventId: string) =>
appRoutes.blog.replace(':naddr', eventId)
export const getProfilePageRoute = (nprofile: string) =>
appRoutes.profile.replace(':nprofile', nprofile)
export const routerWithNdkContext = (context: NDKContextType) =>
createBrowserRouter([
{
element: <Layout />,
children: [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blogs,
element: <BlogsPage />,
loader: blogsRouteLoader(context)
},
{
path: appRoutes.blog,
element: <BlogPage />,
loader: blogRouteLoader(context),
action: blogRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogEdit,
element: <WritePage key='edit' />,
loader: blogRouteLoader(context),
action: writeRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.blogReport_actionOnly,
action: blogReportRouteAction(context)
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage key='write' />,
action: writeRouteAction(context)
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />,
loader: profileRouteLoader(context)
},
{
element: <FeedLayout />,
children: [
{
path: appRoutes.feed,
element: <FeedPage />
},
{
path: appRoutes.notifications,
element: <NotificationsPage />
}
]
},
{
path: '*',
element: <NotFoundPage />
}
]
}
])
export const routes = [
{
path: appRoutes.index,
element: <HomePage />
},
{
path: appRoutes.home,
element: <HomePage />
},
{
path: appRoutes.games,
element: <GamesPage />
},
{
path: appRoutes.game,
element: <GamePage />
},
{
path: appRoutes.mods,
element: <ModsPage />
},
{
path: appRoutes.mod,
element: <ModPage />
},
{
path: appRoutes.about,
element: <AboutPage />
},
{
path: appRoutes.blog,
element: <BlogsPage />
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
},
{
path: appRoutes.write,
element: <WritePage />
},
{
path: appRoutes.search,
element: <SearchPage />
},
{
path: appRoutes.settingsProfile,
element: <SettingsPage />
},
{
path: appRoutes.settingsRelays,
element: <SettingsPage />
},
{
path: appRoutes.settingsPreferences,
element: <SettingsPage />
},
{
path: appRoutes.settingsAdmin,
element: <SettingsPage />
},
{
path: appRoutes.profile,
element: <ProfilePage />
}
]

View File

@ -1,19 +1,18 @@
.IBMSMSMSSS_Author {
padding: 10px;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
border: solid 1px rgba(255,255,255,0.1);
border-radius: 15px;
}
.IBMSMSMSSS_Author_Top {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr;
grid-gap: 15px;
justify-content: start;
align-items: start;
flex-grow: 1;
}
@media (max-width: 576px) {
@ -33,11 +32,9 @@
}
.IBMSMSMSSS_Author_Top_Details {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr;
grid-gap: 15px;
flex-grow: 1;
width: 100%;
}
.HBSS_Author_Top_NostrLinks {
@ -130,6 +127,7 @@
}
.IBMSMSMSSS_Author_Top_Left {
height: 100%;
display: flex;
flex-direction: column;
grid-gap: 15px;
@ -138,6 +136,7 @@
@media (max-width: 576px) {
.IBMSMSMSSS_Author_Top_Left {
height: 100%;
display: flex;
flex-direction: column;
grid-gap: 25px;

View File

@ -46,20 +46,3 @@
backdrop-filter: blur(5px);
}
.cardBlogMainInsideTitle {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 20px;
line-height: 1.5;
color: rgba(255,255,255,0.75);
text-shadow: 0 0 8px rgba(0,0,0,0.25);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard.IBMSMSMBSSTagsTagNSFWCardAlt {
position: absolute;
top: 10px;
right: 10px;
bottom: unset;
}

View File

@ -96,7 +96,6 @@
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
line-clamp: 2;
font-size: 20px;
line-height: 1.25;
color: rgba(255, 255, 255, 0.75);
@ -108,7 +107,6 @@
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
line-clamp: 2;
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
line-height: 1.5;
@ -121,12 +119,11 @@
justify-content: start;
align-items: center;
font-size: 14px;
background: rgba(255, 255, 255, 0.05);
background: rgba(255,255,255,0.05);
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
line-clamp: 1;
}
.cMMFootReactions {
@ -146,21 +143,3 @@
align-items: center;
color: rgba(255, 255, 255, 0.25);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagNSFW.IBMSMSMBSSTagsTagNSFWCard {
position: absolute;
bottom: 10px;
right: 10px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background: rgba(35, 35, 35, 0.85);
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagRepost.IBMSMSMBSSTagsTagRepostCard {
position: absolute;
bottom: 10px;
left: 10px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background: #232323d9;
}

View File

@ -1,18 +0,0 @@
.loading::after {
content: '.';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: '.\00a0\00a0';
}
40% {
content: '..\00a0';
}
60%,
100% {
content: '...';
}
}

View File

@ -229,12 +229,3 @@
color: rgba(255,255,255,0.5);
}
.dropdown.dropdownMain.dropdownMainBlogpost {
flex-grow: unset;
position: absolute;
top: 10px;
right: 10px;
background: #0000002e;
border-radius: 6px;
padding: 2px;
}

View File

@ -22,5 +22,6 @@
flex-wrap: wrap;
grid-gap: 10px;
padding: 10px 0 0 0;
border-top: solid 1px rgba(255, 255, 255, 0.1);
border-top: solid 1px rgba(255,255,255,0.1);
}

View File

@ -44,13 +44,3 @@
cursor: default;
box-shadow: unset;
}
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagRepost {
background: #ffffff1a;
color: #ffffff59;
font-weight: 700;
border: unset;
font-size: 14px;
cursor: default;
box-shadow: unset;
}

View File

@ -1,42 +0,0 @@
export interface BlogForm {
title: string
content: string
image: string
summary: string
tags: string
nsfw: boolean
}
export interface BlogDetails extends BlogForm {
id: string
author: string
published_at: number
edited_at: number
rTag: string
dTag: string
aTag: string
tTags: string[]
}
export interface BlogEventSubmitForm extends Omit<BlogForm, 'nsfw'> {
nsfw: string
}
export interface BlogEventEditForm extends BlogEventSubmitForm {
dTag: string
rTag: string
published_at: string
}
export interface BlogFormErrors extends Partial<BlogEventSubmitForm> {}
export interface BlogCardDetails extends BlogDetails {
naddr: string
}
export interface BlogPageLoaderResult {
blog: Partial<BlogDetails> | undefined
latest: Partial<BlogDetails>[]
isAddedToNSFW: boolean
isBlocked: boolean
}

View File

@ -3,4 +3,3 @@ export * from './modsFilter'
export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'

View File

@ -7,9 +7,3 @@ export interface SignedEvent {
id: string
sig: string
}
export interface Addressable {
author: string
id: string
aTag: string
}

View File

@ -1,9 +1,3 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
export type UserProfile = NDKUserProfile | null
export enum UserRelaysType {
Read = 'readRelayUrls',
Write = 'writeRelayUrls',
Both = 'bothRelayUrls'
}

View File

@ -1,52 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { BlogCardDetails, BlogDetails } from 'types'
import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr'
import { kinds, nip19 } from 'nostr-tools'
export const extractBlogDetails = (event: NDKEvent): Partial<BlogDetails> => {
const dTag = getFirstTagValue(event, 'd')
// Check if the aTag exists on the blog
let aTag = getFirstTagValue(event, 'a')
// Create aTag from components if aTag is not included
if (typeof aTag === 'undefined' && event.pubkey && dTag) {
aTag = `${kinds.LongFormArticle}:${event.pubkey}:${dTag}`
}
return {
title: getFirstTagValue(event, 'title'),
content: event.content,
summary: getFirstTagValue(event, 'summary'),
image: getFirstTagValue(event, 'image'),
// Check L label namespace for content warning or nsfw (backwards compatibility)
nsfw:
getFirstTagValue(event, 'L') === 'content-warning' ||
getFirstTagValue(event, 'nsfw') === 'true',
id: event.id,
author: event.pubkey,
published_at: getFirstTagValueAsInt(event, 'published_at'),
edited_at: event.created_at,
rTag: getFirstTagValue(event, 'r') || 'N/A',
dTag: dTag,
aTag: aTag,
tTags: getTagValues(event, 't') || []
}
}
export const extractBlogCardDetails = (
event: NDKEvent
): Partial<BlogCardDetails> => {
const blogDetails = extractBlogDetails(event)
return {
...blogDetails,
naddr: blogDetails.dTag
? nip19.naddrEncode({
identifier: blogDetails.dTag,
kind: kinds.LongFormArticle,
pubkey: event.pubkey
})
: undefined
}
}

View File

@ -1,8 +0,0 @@
import { FilterOptions, SortBy, NSFWFilter, ModeratedFilter } from 'types'
export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
moderated: ModeratedFilter.Moderated
}

View File

@ -3,6 +3,3 @@ export * from './nostr'
export * from './url'
export * from './utils'
export * from './zap'
export * from './localStorage'
export * from './consts'
export * from './blog'

View File

@ -1,32 +0,0 @@
export function getLocalStorageItem<T>(key: string, defaultValue: T): string {
try {
const data = window.localStorage.getItem(key)
if (data === null) return JSON.stringify(defaultValue)
return data
} catch (err) {
console.error(`Error while fetching local storage value: `, err)
return JSON.stringify(defaultValue)
}
}
export function setLocalStorageItem(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
dispatchLocalStorageEvent(key, value)
} catch (err) {
console.error(`Error while saving local storage value: `, err)
}
}
export function removeLocalStorageItem(key: string) {
try {
window.localStorage.removeItem(key)
dispatchLocalStorageEvent(key, null)
} catch (err) {
console.error(`Error while deleting local storage value: `, err)
}
}
function dispatchLocalStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }))
}

View File

@ -9,8 +9,9 @@ import {
UnsignedEvent
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { RelayController } from '../controllers'
import { log, LogType } from './utils'
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'
import { NDKEvent } from '@nostr-dev-kit/ndk'
/**
* Get the current time in seconds since the Unix epoch (January 1, 1970).
@ -65,38 +66,6 @@ export const getTagValue = (
return null
}
export const getTagValues = (
event: Event | NDKEvent,
tagIdentifier: string
): string[] | null => {
// Find the tag in the event's tags array where the first element matches the tagIdentifier.
const tags = event.tags.filter((item) => item[0] === tagIdentifier)
// If a matching tag is found, return the rest of the elements in the tag (i.e., the values).
if (tags && tags.length) {
return tags.map((item) => item[1]) // Returning only the values
}
// Return null if no matching tag is found.
return null
}
export const getFirstTagValue = (
event: Event | NDKEvent,
tagIdentifier: string
) => {
const tags = getTagValues(event, tagIdentifier)
return tags && tags.length ? tags[0] : undefined
}
export const getFirstTagValueAsInt = (
event: Event | NDKEvent,
tagIdentifier: string
) => {
const value = getFirstTagValue(event, tagIdentifier)
return value ? parseInt(value, 10) : undefined
}
/**
* @param hexKey hex private or public key
* @returns whether or not is key valid
@ -154,11 +123,7 @@ export const extractZapAmount = (event: Event): number => {
* @param unsignedEvent - The event object which needs to be signed before publishing.
* @returns - A promise that resolves to boolean indicating whether the event was successfully signed and published
*/
export const signAndPublish = async (
unsignedEvent: UnsignedEvent,
ndk: NDK,
publish: (event: NDKEvent) => Promise<string[]>
) => {
export const signAndPublish = async (unsignedEvent: UnsignedEvent) => {
// Sign the event. This returns a signed event or null if signing fails.
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
@ -173,10 +138,11 @@ export const signAndPublish = async (
// If the event couldn't be signed, exit the function and return null.
if (!signedEvent) return false
// Publish the signed event to the relays.
// Publish the signed event to the relays using the RelayController.
// This returns an array of relay URLs where the event was successfully published.
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {
@ -204,9 +170,7 @@ export const signAndPublish = async (
*/
export const sendDMUsingRandomKey = async (
message: string,
receiver: string,
ndk: NDK,
publish: (event: NDKEvent) => Promise<string[]>
receiver: string
) => {
// Generate a random secret key for encrypting the message
const secretKey = generateSecretKey()
@ -237,8 +201,11 @@ export const sendDMUsingRandomKey = async (
// Finalize and sign the event using the generated secret key
const signedEvent = finalizeEvent(unsignedEvent, secretKey)
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
// Publish the signed event (the encrypted DM) to the relays
const publishedOnRelays = await RelayController.getInstance().publishDM(
signedEvent,
receiver
)
// Handle cases where publishing to the relays failed
if (publishedOnRelays.length === 0) {

View File

@ -135,24 +135,3 @@ export const handleModImageError = (
) => {
e.currentTarget.src = import.meta.env.VITE_FALLBACK_MOD_IMAGE
}
export const scrollIntoView = (el: HTMLElement | null) => {
if (el) {
setTimeout(() => {
el.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}, 100)
}
}
export const parseFormData = <T>(formData: FormData) => {
const result: Partial<T> = {}
formData.forEach(
(value, key) => ((result as Record<string, unknown>)[key] = value as string)
)
return result
}

1
src/vite-env.d.ts vendored
View File

@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_REPORTING_NPUB: string
readonly VITE_FALLBACK_MOD_IMAGE: string
readonly VITE_FALLBACK_GAME_IMAGE: string
readonly VITE_BLOG_NPUBS: string
// more env variables...
}