Compare commits
101 Commits
freakovers
...
staging
Author | SHA1 | Date | |
---|---|---|---|
11de23b7d2 | |||
a912e3e43c | |||
df0c64e2c9 | |||
|
2ed81c857c | ||
|
18bbc12776 | ||
|
f7d21807a4 | ||
|
cd3c7ace01 | ||
|
aaffc56424 | ||
|
7b1a70446d | ||
|
4140438044 | ||
|
d6bc3b8684 | ||
296b0ad61d | |||
|
0f874c6bbb | ||
|
297de3999c | ||
|
49435c2b50 | ||
|
718350d2bc | ||
|
3ee9e313de | ||
|
91830a539a | ||
|
834701aa2c | ||
0c7e61cadd | |||
|
b49ae9537b | ||
|
352179f1d9 | ||
|
77b6aa0d75 | ||
|
2c31c279a1 | ||
|
7be41272a0 | ||
cea814676e | |||
bd6515ca53 | |||
fedd7dd463 | |||
7ceb109bab | |||
b3747e9c22 | |||
d10b10a4fb | |||
2fe0a79009 | |||
8af73df889 | |||
0d10890ca3 | |||
4176b06bee | |||
|
178876ab99 | ||
|
1c1430ba5c | ||
|
2f563e1bfb | ||
|
f7f3764686 | ||
f734b1447f | |||
|
6d6ff8ce43 | ||
31fd4ddfb5 | |||
|
f30ac01ea6 | ||
|
31ee0221b7 | ||
|
c81b2c0a1d | ||
|
dae94733fa | ||
|
2f32f400dd | ||
|
1d0f27d255 | ||
|
a3bec707b0 | ||
|
169ab37304 | ||
|
73a7b1c1ee | ||
|
847aab29d7 | ||
|
3717c3bfb9 | ||
|
c2413e1bd8 | ||
|
d4148ed01d | ||
7f0431f8f8 | |||
3a440d5479 | |||
40dd903e97 | |||
68ebaf38bd | |||
36aeb53a8c | |||
|
c25d3da64b | ||
|
4eb8c7d653 | ||
43c8ae4066 | |||
49c1168bb7 | |||
69768388e4 | |||
80172aee07 | |||
|
0f6cd4a9bd | ||
|
35fdf2c8b7 | ||
e41ce32ef2 | |||
|
0ee3dba906 | ||
|
efad0f44f5 | ||
|
6e07f4b8be | ||
|
7640bdd53b | ||
|
72252d416b | ||
|
6e4fa104c0 | ||
f2f80a36c6 | |||
|
4f4e3a7c85 | ||
|
0b1d43eac4 | ||
|
2dc0ab6cf4 | ||
|
15af98359d | ||
|
3906c70bc9 | ||
|
9341cd6544 | ||
bc782c775a | |||
|
d3c2d5fe7a | ||
|
d96e5088b8 | ||
|
9aa57c1adf | ||
|
8ee6f98654 | ||
|
84cb5b6912 | ||
9b8bf01d33 | |||
|
53861a36d3 | ||
|
99ce338502 | ||
|
76478ad572 | ||
|
7393940027 | ||
|
bb653fa356 | ||
|
2e367ecde8 | ||
|
a95cd8b6ec | ||
|
8810673492 | ||
|
a97a034178 | ||
|
0102f41403 | ||
63333b38c3 | |||
|
38280d151a |
@ -11,4 +11,7 @@ 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
|
||||
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>
|
||||
|
@ -1,4 +1,4 @@
|
||||
name: Release to Staging
|
||||
name: Release to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@ -27,6 +27,8 @@ 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
|
||||
|
@ -27,6 +27,8 @@ 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
|
||||
|
@ -35,6 +35,8 @@ 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
|
||||
|
41
index.html
41
index.html
@ -9,6 +9,47 @@
|
||||
<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
400
package-lock.json
generated
@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "degmods.com",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.0-alpha-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "degmods.com",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.0-alpha-1",
|
||||
"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.6.6",
|
||||
"@tiptap/extension-link": "2.6.6",
|
||||
"@tiptap/react": "2.6.6",
|
||||
"@tiptap/starter-kit": "2.6.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",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "1.7.3",
|
||||
"bech32": "2.0.0",
|
||||
@ -26,6 +27,7 @@
|
||||
"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",
|
||||
@ -39,6 +41,7 @@
|
||||
"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"
|
||||
},
|
||||
@ -51,6 +54,7 @@
|
||||
"@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",
|
||||
@ -1038,6 +1042,12 @@
|
||||
"@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",
|
||||
@ -1152,6 +1162,7 @@
|
||||
"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"
|
||||
@ -1181,9 +1192,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz",
|
||||
"integrity": "sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ=="
|
||||
"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"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.17.1",
|
||||
@ -1479,45 +1491,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz",
|
||||
"integrity": "sha512-VO5qTsjt6rwworkuo0s5AqYMfDA0ZwiTiH6FHKFSu2G/6sS7HKcc/LjPq+5Legzps4QYdBDl3W28wGsGuS1GdQ==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.9.1.tgz",
|
||||
"integrity": "sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.6.6.tgz",
|
||||
"integrity": "sha512-hAdsNlMfzzxld154hJqPqtWqO5i4/7HoDfuxmyqBxdMJ+e2UMaIGBGwoLRXG0V9UoRwJusjqlpyD7pIorxNlgA==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.6.6.tgz",
|
||||
"integrity": "sha512-CD6gBhdQtCoqYSmx8oAV8gvKtVOGZSyyvuNYo7by9eZ56DqLYnd7kbUj0RH7o9Ymf/iJTOUJ6XcvrsWwo4lubg==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"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==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
@ -1526,76 +1542,82 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"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==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.6.6.tgz",
|
||||
"integrity": "sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"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==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.6.6.tgz",
|
||||
"integrity": "sha512-6qlH5VWzLHHRVeeciRC6C4ZHpMsAGPNG16EF53z0GeMSaaFD/zU3B239QlmqXmLsAl8bpf8Bn93N0t2ABUvScw==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.6.6.tgz",
|
||||
"integrity": "sha512-O6CeKriA9uyHsg7Ui4z5ZjEWXQxrIL+1zDekffW0wenGC3G4LUsCzAiFS4LSrR9a3u7tnwqGApW10rdkmCGF4w==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.9.1.tgz",
|
||||
"integrity": "sha512-wJZspSmJRkDBtPkzFz1g7gvZOEOayk8s93UHsgbJxcV4VWHYleZ5XhT74sZunSjefNDm3qC6v2BSgLp3vNHVKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"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==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
@ -1604,89 +1626,109 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.6.6.tgz",
|
||||
"integrity": "sha512-O2lQ2t0X0Vsbn3yLWxFFHrXY6C2N9Y6ZF/M7LWzpcDTUZeWuhoNkFE/1yOM0h6ZX1DO2A9hNIrKpi5Ny8yx+QA==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"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==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.6.6.tgz",
|
||||
"integrity": "sha512-bgx9vptVFi5yFkIw1OI53J7+xJ71Or3SOe/Q8eSpZv53DlaKpL/TzKw8Z54t1PrI2rJ6H9vrLtkvixJvBZH1Ug==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.9.1.tgz",
|
||||
"integrity": "sha512-SjZowzLixOFaCrV2cMaWi1mp8REK0zK1b3OcVx7bCZfVSmsOETJyrAIUpCKA8o60NwF7pwhBg0MN8oXlNKMeFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-history": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.6.6.tgz",
|
||||
"integrity": "sha512-tPTzAmPGqMX5Bd5H8lzRpmsaMvB9DvI5Dy2za/VQuFtxgXmDiFVgHRkRXIuluSkPTuANu84XBOQ0cBijqY8x4w==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.9.1.tgz",
|
||||
"integrity": "sha512-wp9qR1NM+LpvyLZFmdNaAkDq0d4jDJ7z7Fz7icFQPu31NVxfQYO3IXNmvJDCNu8hFAbImpA5aG8MBuwzRo0H9w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"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==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.6.6.tgz",
|
||||
"integrity": "sha512-t7ZPsXqa8nJZZ/6D0rQyZ/KsvzLaSihC6hBTjUQ77CeDGV9PhDWjIcBW4OrvwraJDBd12ETBeQ2CkULJOgH+lQ==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.6.6.tgz",
|
||||
"integrity": "sha512-NJSR5Yf/dI3do0+Mr6e6nkbxRQcqbL7NOPxo5Xw8VaKs2Oe8PX+c7hyqN3GZgn6uEbZdbVi1xjAniUokouwpFg==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.9.1.tgz",
|
||||
"integrity": "sha512-yG+e3e8cCCN9dZjX4ttEe3e2xhh58ryi3REJV4MdiEkOT9QF75Bl5pUbMIS4tQ8HkOr04QBFMHKM12kbSxg1BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.1.0"
|
||||
},
|
||||
@ -1695,78 +1737,97 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"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==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.6.6.tgz",
|
||||
"integrity": "sha512-AJwyfLXIi7iUGnK5twJbwdVVpQyh7fU6OK75h1AwDztzsOcoPcxtffDlZvUOd4ZtwuyhkzYqVkeI0f+abTWZTw==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.6.6.tgz",
|
||||
"integrity": "sha512-fD/onCr16UQWx+/xEmuFC2MccZZ7J5u4YaENh8LMnAnBXf78iwU7CAcmuc9rfAEO3qiLoYGXgLKiHlh2ZfD4wA==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.6.6.tgz",
|
||||
"integrity": "sha512-Ze8KhGk+wzSJSJRl5fbhTI6AvPu2LmcHYeO3pMEH8u4gV5WTXfmKJVStEIAzkoqvwEQVWzXvy8nDgsFQHiojPg==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.6.6.tgz",
|
||||
"integrity": "sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==",
|
||||
"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",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.6.6.tgz",
|
||||
"integrity": "sha512-56FGLPn3fwwUlIbLs+BO21bYfyqP9fKyZQbQyY0zWwA/AG2kOwoXaRn7FOVbjP6CylyWpFJnpRRmgn694QKHEg==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.9.1.tgz",
|
||||
"integrity": "sha512-mvV86fr7kEuDYEApQ2uMPCKL2uagUE0BsXiyyz3KOkY1zifyVm1fzdkscb24Qy1GmLzWAIIihA+3UHNRgYdOlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.2.1",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.5.2",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
@ -1774,14 +1835,14 @@
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.0",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.22.2",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"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": "^2.0.9",
|
||||
"prosemirror-transform": "^1.9.0",
|
||||
"prosemirror-view": "^1.33.9"
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.0",
|
||||
"prosemirror-view": "^1.34.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -1789,13 +1850,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.6.6.tgz",
|
||||
"integrity": "sha512-AUmdb/J1O/vCO2b8LL68ctcZr9a3931BwX4fUUZ1kCrCA5lTj2xz0rjeAtpxEdzLnR+Z7q96vB7vf7bPYOUAew==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^2.6.6",
|
||||
"@tiptap/extension-floating-menu": "^2.6.6",
|
||||
"@tiptap/extension-bubble-menu": "^2.9.1",
|
||||
"@tiptap/extension-floating-menu": "^2.9.1",
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-deep-equal": "^3",
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"funding": {
|
||||
@ -1803,8 +1866,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6",
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
}
|
||||
@ -1815,30 +1878,32 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.6.6.tgz",
|
||||
"integrity": "sha512-zb9xIg3WjG9AsJoyWrfqx5SL9WH7/HTdkB79jFpWtOF/Kaigo7fHFmhs2FsXtJMJlcdMTO2xeRuCYHt5ozXlhg==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -2032,6 +2097,13 @@
|
||||
"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",
|
||||
@ -3193,8 +3265,7 @@
|
||||
"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==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
@ -3847,6 +3918,18 @@
|
||||
"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",
|
||||
@ -4504,11 +4587,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
"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==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@remirror/core-constants": "^2.0.2",
|
||||
"@remirror/core-constants": "3.0.0",
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -4521,6 +4605,7 @@
|
||||
"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"
|
||||
},
|
||||
@ -4537,9 +4622,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.34.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.34.1.tgz",
|
||||
"integrity": "sha512-KS2xmqrAM09h3SLu1S2pNO/ZoIP38qkTJ6KFd7+BeSfmX/ek0n5yOfGuiTZjFNTC8GOsEIUa1tHxt+2FMu3yWQ==",
|
||||
"version": "1.35.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.35.0.tgz",
|
||||
"integrity": "sha512-Umtbh22fmUlpZpRTiOVXA0PpdRZeYEeXQsLp51VfnMhjkJrqJ0n8APinIZrRAD5Jr3UxH8FnOaUqRylSuMsqHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
@ -5028,6 +5114,7 @@
|
||||
"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"
|
||||
}
|
||||
@ -5149,6 +5236,15 @@
|
||||
"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",
|
||||
|
14
package.json
14
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "degmods.com",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.0-alpha-1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -14,10 +14,11 @@
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"@tiptap/core": "2.6.6",
|
||||
"@tiptap/extension-link": "2.6.6",
|
||||
"@tiptap/react": "2.6.6",
|
||||
"@tiptap/starter-kit": "2.6.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",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "1.7.3",
|
||||
"bech32": "2.0.0",
|
||||
@ -28,6 +29,7 @@
|
||||
"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,6 +43,7 @@
|
||||
"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"
|
||||
},
|
||||
@ -53,6 +56,7 @@
|
||||
"@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",
|
||||
|
51
public/404.html
Normal file
51
public/404.html
Normal file
@ -0,0 +1,51 @@
|
||||
<!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>
|
22
src/App.tsx
22
src/App.tsx
@ -1,10 +1,12 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Layout } from './layout'
|
||||
import { routes } from './routes'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
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')
|
||||
@ -22,19 +24,7 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{routes.map((route, index) => (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
return <RouterProvider router={routerWithNdkContext(ndkContext)} />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -38405,7 +38405,7 @@ Trap Legend Theme Song,,
|
||||
Gemini Strategy Origin,,
|
||||
Brain Marmelade,,
|
||||
Forsaken Flesh,,
|
||||
"Warhammer 40,000: Darktide",,
|
||||
"Warhammer 40,000: Darktide",,https://image.nostr.build/0c405bdb168c05f21f54b3bda39852eb2f9fa851068367eccd69f2bc0526a600.jpg
|
||||
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.
|
@ -45067,7 +45067,7 @@ Rooftop Postgirl Demo,,
|
||||
Mad Experiments 2: Premium Pack,,
|
||||
Arise from Shadows Demo,,
|
||||
Hentai Heaven's Slutty Salvation,,
|
||||
Fields of Mistria,,
|
||||
Fields of Mistria,,https://image.nostr.build/deb8fb380cdf2f42750f115141d762de791bf49e0af97ec733390360a1a5ddbf.jpg
|
||||
Maskonauts: Chat'Attack Soundtrack,,
|
||||
Nameless - The Departed Cycle,,
|
||||
Grave-Queen,,
|
||||
|
Can't render this file because it is too large.
|
@ -1,2 +1,2 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
|
Binary file not shown.
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 314 KiB |
@ -1,37 +1,33 @@
|
||||
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 = {
|
||||
backgroundLink: string
|
||||
}
|
||||
type BlogCardProps = Partial<BlogCardDetails>
|
||||
|
||||
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
|
||||
if (!naddr) return null
|
||||
|
||||
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
|
||||
return (
|
||||
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
|
||||
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
|
||||
<div
|
||||
className='cardBlogMain'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
background: `url("${
|
||||
image ? image : placeholder
|
||||
}") center / cover no-repeat`
|
||||
}}
|
||||
>
|
||||
<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 className='cardBlogMainInside'>
|
||||
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
|
||||
{nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{' '}
|
||||
</a>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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'
|
||||
@ -89,13 +90,27 @@ interface CheckboxFieldProps {
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
type?: 'default' | 'stylized'
|
||||
}
|
||||
|
||||
export const CheckboxField = React.memo(
|
||||
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
({
|
||||
label,
|
||||
name,
|
||||
isChecked,
|
||||
handleChange,
|
||||
type = 'default'
|
||||
}: CheckboxFieldProps) => (
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
|
||||
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
|
||||
}`}
|
||||
>
|
||||
<label htmlFor={name} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
@ -113,7 +128,15 @@ type RichTextEditorProps = {
|
||||
|
||||
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Link],
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'IBMSMSMBSSPostImg'
|
||||
}
|
||||
})
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
// Update the state when the editor content changes
|
||||
updateContent(editor.getHTML())
|
||||
@ -144,7 +167,7 @@ type MenuBarProps = {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const MenuBar = ({ editor }: MenuBarProps) => {
|
||||
export const MenuBar = ({ editor }: MenuBarProps) => {
|
||||
const setLink = () => {
|
||||
// Prompt the user to enter a URL
|
||||
let url = prompt('URL')
|
||||
@ -167,6 +190,17 @@ 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',
|
||||
@ -197,7 +231,7 @@ const MenuBar = ({ editor }: MenuBarProps) => {
|
||||
{
|
||||
label: 'Paragraph',
|
||||
isActive: editor.isActive('paragraph'),
|
||||
onClick: () => editor.chain().focus().toggleStrike().run()
|
||||
onClick: () => editor.chain().focus().setParagraph().run()
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
|
||||
@ -230,6 +264,11 @@ 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()
|
||||
@ -284,7 +323,55 @@ 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>
|
||||
)
|
||||
|
42
src/components/Internal/Interactions.tsx
Normal file
42
src/components/Internal/Interactions.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
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>
|
||||
)
|
||||
}
|
86
src/components/Internal/PublishDetails.tsx
Normal file
86
src/components/Internal/PublishDetails.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,11 +1,12 @@
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { useReactions } from 'hooks'
|
||||
import { ModDetails } from 'types'
|
||||
import { Addressable } from 'types'
|
||||
|
||||
type ReactionsProps = {
|
||||
modDetails: ModDetails
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Reactions = ({ modDetails }: ReactionsProps) => {
|
||||
export const Reactions = ({ addressable }: ReactionsProps) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
@ -14,20 +15,18 @@ export const Reactions = ({ modDetails }: ReactionsProps) => {
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: modDetails.author,
|
||||
eTag: modDetails.id,
|
||||
aTag: modDetails.aTag
|
||||
pubkey: addressable.author,
|
||||
eTag: addressable.id,
|
||||
aTag: addressable.aTag
|
||||
})
|
||||
|
||||
if (!isDataLoaded) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction(true)}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
@ -41,7 +40,9 @@ export const Reactions = ({ modDetails }: 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'>{likesCount}</p>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
@ -50,7 +51,7 @@ export const Reactions = ({ modDetails }: ReactionsProps) => {
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction()}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
@ -64,7 +65,9 @@ export const Reactions = ({ modDetails }: 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'>{disLikesCount}</p>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
@ -1,31 +1,36 @@
|
||||
import { ZapSplit } from 'components/Zap'
|
||||
import { RelayController } from 'controllers'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ModDetails } from 'types'
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
|
||||
type ZapProps = {
|
||||
modDetails: ModDetails
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Zap = ({ modDetails }: ZapProps) => {
|
||||
export const Zap = ({ addressable }: 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()
|
||||
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.getTotalZapAmount(
|
||||
modDetails.author,
|
||||
modDetails.id,
|
||||
modDetails.aTag,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
getTotalZapAmount(
|
||||
addressable.author,
|
||||
addressable.id,
|
||||
addressable.aTag,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
@ -65,9 +70,9 @@ export const Zap = ({ modDetails }: ZapProps) => {
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapSplit
|
||||
pubkey={modDetails.author}
|
||||
eventId={modDetails.id}
|
||||
aTag={modDetails.aTag}
|
||||
pubkey={addressable.author}
|
||||
eventId={addressable.id}
|
||||
aTag={addressable.aTag}
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
handleClose={() => setIsOpen(false)}
|
@ -5,24 +5,23 @@ import { handleModImageError } from '../utils'
|
||||
import { ModDetails } from 'types'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { kinds, nip19 } from 'nostr-tools'
|
||||
import { useDidMount, useReactions } from 'hooks'
|
||||
import { RelayController } from 'controllers'
|
||||
import { useDidMount, useNDKContext, useReactions } from 'hooks'
|
||||
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)
|
||||
const { commentEvents } = useComments(props.author, props.aTag)
|
||||
const { likesCount, disLikesCount } = useReactions({
|
||||
pubkey: props.author,
|
||||
eTag: props.id,
|
||||
aTag: props.aTag
|
||||
})
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.getTotalZapAmount(props.author, props.id, props.aTag)
|
||||
getTotalZapAmount(props.author, props.id, props.aTag)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
})
|
||||
@ -51,7 +50,13 @@ 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>
|
||||
|
@ -13,8 +13,7 @@ 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 { RelayController } from '../controllers'
|
||||
import { useAppSelector, useGames } from '../hooks'
|
||||
import { useAppSelector, useGames, useNDKContext } from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import '../styles/styles.css'
|
||||
import { DownloadUrl, ModDetails, ModFormState } from '../types'
|
||||
@ -29,6 +28,7 @@ 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,6 +54,7 @@ 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)
|
||||
|
||||
@ -191,7 +192,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const uuid = formState.dTag || uuidv4()
|
||||
const currentTimeStamp = now()
|
||||
|
||||
const aTag =
|
||||
@ -203,7 +204,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
pubkey: hexPubkey,
|
||||
content: formState.body,
|
||||
tags: [
|
||||
['d', formState.dTag || uuid],
|
||||
['d', uuid],
|
||||
['a', aTag],
|
||||
['r', formState.rTag],
|
||||
['t', T_TAG_VALUE],
|
||||
@ -243,9 +244,8 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -395,6 +395,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
name='nsfw'
|
||||
isChecked={formState.nsfw}
|
||||
handleChange={handleCheckboxChange}
|
||||
type='stylized'
|
||||
/>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
@ -763,8 +764,9 @@ 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'>
|
||||
@ -825,10 +827,12 @@ 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>
|
||||
)
|
||||
|
@ -1,16 +1,20 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { useAppSelector, useLocalStorage } 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 = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ filterOptions, setFilterOptions }: Props) => {
|
||||
({ author, filterKey = 'filter' }: Props) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
@ -61,7 +65,12 @@ export const ModFilter = React.memo(
|
||||
userState.user?.npub ===
|
||||
import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (!isAdmin) return null
|
||||
const isOwnProfile =
|
||||
author &&
|
||||
userState.auth &&
|
||||
userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -4,13 +4,17 @@ import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { RelayController, UserRelaysType } from '../controllers'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
import { appRoutes, getProfilePageRoute } from '../routes'
|
||||
import '../styles/author.css'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/socialPosts.css'
|
||||
import { UserProfile } from '../types'
|
||||
import { UserRelaysType } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
hexToNpub,
|
||||
@ -22,37 +26,19 @@ 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}
|
||||
displayName={displayName}
|
||||
about={about}
|
||||
image={profile?.image}
|
||||
nip05={profile?.nip05}
|
||||
lud16={profile?.lud16}
|
||||
/>
|
||||
<Profile pubkey={pubkey} />
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
@ -104,21 +90,18 @@ export const ProfileSection = ({ pubkey }: Props) => {
|
||||
|
||||
type ProfileProps = {
|
||||
pubkey: string
|
||||
displayName: string
|
||||
about: string
|
||||
image?: string
|
||||
nip05?: string
|
||||
lud16?: string
|
||||
}
|
||||
|
||||
export const Profile = ({
|
||||
pubkey,
|
||||
displayName,
|
||||
about,
|
||||
image,
|
||||
nip05,
|
||||
lud16
|
||||
}: ProfileProps) => {
|
||||
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
|
||||
|
||||
const npub = hexToNpub(pubkey)
|
||||
|
||||
const handleCopy = async () => {
|
||||
@ -133,14 +116,20 @@ export const Profile = ({
|
||||
})
|
||||
}
|
||||
|
||||
// Try to encode
|
||||
let profileRoute = appRoutes.home
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
if (hexPubkey) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -157,9 +146,7 @@ export const Profile = ({
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background: `url('${
|
||||
image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
background: `url('${image}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@ -167,7 +154,8 @@ export const Profile = ({
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
{nip05 && (
|
||||
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
|
||||
{typeof nip05 === 'string' && (
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
@ -200,8 +188,12 @@ export const Profile = ({
|
||||
<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>
|
||||
<ProfileQRButtonWithPopUp pubkey={pubkey} />
|
||||
{lud16 && <ZapButtonWithPopUp pubkey={pubkey} />}
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
{typeof lud16 !== 'undefined' && (
|
||||
<ZapButtonWithPopUp pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -246,17 +238,15 @@ const posts: Post[] = [
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
nprofile: string
|
||||
}
|
||||
|
||||
export const ProfileQRButtonWithPopUp = ({
|
||||
pubkey
|
||||
nprofile
|
||||
}: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const nprofile = nip19.nprofileEncode({
|
||||
pubkey
|
||||
})
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
@ -335,6 +325,8 @@ type ZapButtonWithPopUpProps = {
|
||||
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -368,7 +360,7 @@ type FollowButtonProps = {
|
||||
}
|
||||
|
||||
const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
const { fetchEventFromUserRelays } = useNDKContext()
|
||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@ -441,9 +433,8 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
|
39
src/components/SearchInput.tsx
Normal file
39
src/components/SearchInput.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
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>
|
||||
)
|
||||
)
|
9
src/components/Spinner.tsx
Normal file
9
src/components/Spinner.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
26
src/components/Tabs.tsx
Normal file
26
src/components/Tabs.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { getRelayListForUser } from '@nostr-dev-kit/ndk'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import React, {
|
||||
Dispatch,
|
||||
@ -9,7 +10,7 @@ import React, {
|
||||
} from 'react'
|
||||
import Countdown, { CountdownRenderProps } from 'react-countdown'
|
||||
import { toast } from 'react-toastify'
|
||||
import { MetadataController, ZapController } from '../controllers'
|
||||
import { ZapController } from '../controllers'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
|
||||
import '../styles/popup.css'
|
||||
import { PaymentRequest, UserProfile } from '../types'
|
||||
@ -251,7 +252,7 @@ export const ZapPopUp = ({
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapPopUpProps) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
@ -300,6 +301,20 @@ 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')
|
||||
@ -308,6 +323,7 @@ export const ZapPopUp = ({
|
||||
receiverMetadata.lud16,
|
||||
amount,
|
||||
receiverMetadata.pubkey as string,
|
||||
receiverRelays,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
@ -320,7 +336,7 @@ export const ZapPopUp = ({
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, message, userState, receiver, eventId, aTag])
|
||||
}, [amount, message, userState, receiver, eventId, aTag, ndk, findMetadata])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
@ -482,7 +498,7 @@ export const ZapSplit = ({
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapSplitProps) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
@ -502,8 +518,8 @@ export const ZapSplit = ({
|
||||
setAuthor(res)
|
||||
})
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
findMetadata(metadataController.adminNpubs[0]).then((res) => {
|
||||
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
findMetadata(adminNpubs[0]).then((res) => {
|
||||
setAdmin(res)
|
||||
})
|
||||
})
|
||||
@ -557,12 +573,30 @@ 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,
|
||||
@ -579,12 +613,27 @@ 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,
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { Dots, Spinner } from 'components/Spinner'
|
||||
import { ZapPopUp } from 'components/Zap'
|
||||
import {
|
||||
MetadataController,
|
||||
RelayController,
|
||||
UserRelaysType
|
||||
} from 'controllers'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useAppSelector, useDidMount, useNDKContext, useReactions } from 'hooks'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext,
|
||||
useReactions
|
||||
} from 'hooks'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import React, {
|
||||
@ -19,9 +22,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'
|
||||
@ -42,17 +45,30 @@ type FilterOptions = {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
modDetails: ModDetails
|
||||
addressable: Addressable
|
||||
setCommentCount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Comments = ({ modDetails, setCommentCount }: Props) => {
|
||||
const { commentEvents, setCommentEvents } = useComments(modDetails)
|
||||
export const Comments = ({ addressable, setCommentCount }: Props) => {
|
||||
const { ndk, publish } = useNDKContext()
|
||||
const { commentEvents, setCommentEvents } = useComments(
|
||||
addressable.author,
|
||||
addressable.aTag
|
||||
)
|
||||
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])
|
||||
@ -81,8 +97,9 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => {
|
||||
kind: kinds.ShortTextNote,
|
||||
created_at: now(),
|
||||
tags: [
|
||||
['e', modDetails.id],
|
||||
['a', modDetails.aTag]
|
||||
['e', addressable.id],
|
||||
['a', addressable.aTag],
|
||||
['p', addressable.author]
|
||||
]
|
||||
}
|
||||
|
||||
@ -105,28 +122,52 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => {
|
||||
...prev
|
||||
])
|
||||
|
||||
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
|
||||
)
|
||||
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 combinedRelays = [
|
||||
...new Set(...modAuthorReadRelays, ...commentatorWriteRelays)
|
||||
]
|
||||
return event
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Published
|
||||
}
|
||||
}
|
||||
|
||||
const publishedOnRelays =
|
||||
await RelayController.getInstance().publishOnRelays(
|
||||
signedEvent,
|
||||
combinedRelays
|
||||
)
|
||||
return event
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
// when an event is successfully published remove the status from it after 15 seconds
|
||||
setTimeout(() => {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
delete event.status
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
}, 15000)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('An error occurred in publishing comment', err)
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
@ -139,45 +180,26 @@ export const Comments = ({ modDetails, 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 = commentEvents
|
||||
let filteredComments = visible
|
||||
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
|
||||
filteredComments = filteredComments.filter(
|
||||
(comment) => comment.pubkey === modDetails.author
|
||||
(comment) => comment.pubkey === addressable.author
|
||||
)
|
||||
}
|
||||
|
||||
@ -188,13 +210,28 @@ export const Comments = ({ modDetails, setCommentCount }: Props) => {
|
||||
}
|
||||
|
||||
return filteredComments
|
||||
}, [commentEvents, filterOptions, modDetails.author])
|
||||
}, [visible, filterOptions.author, filterOptions.sort, addressable.author])
|
||||
|
||||
const discoveredCount = commentEvents.length - visible.length
|
||||
return (
|
||||
<div className='IBMSMSMBSSCommentsWrapper'>
|
||||
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
|
||||
<div className='IBMSMSMBSSComments'>
|
||||
<CommentForm handleSubmit={handleSubmit} />
|
||||
{/* 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>
|
||||
<Filter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
@ -353,12 +390,12 @@ const Comment = (props: CommentEvent) => {
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(props.pubkey)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
@ -444,15 +481,13 @@ const Reactions = (props: Event) => {
|
||||
eTag: props.id
|
||||
})
|
||||
|
||||
if (!isDataLoaded) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction(true)}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -464,7 +499,9 @@ 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'>{likesCount}</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
@ -473,7 +510,7 @@ const Reactions = (props: Event) => {
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction()}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -485,7 +522,9 @@ 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'>{disLikesCount}</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
@ -496,20 +535,21 @@ 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()
|
||||
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.getTotalZapAmount(
|
||||
props.pubkey,
|
||||
props.id,
|
||||
undefined,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
getTotalZapAmount(
|
||||
props.pubkey,
|
||||
props.id,
|
||||
undefined,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
@ -20,6 +20,12 @@ 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
|
||||
@ -112,7 +118,8 @@ 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
|
||||
|
@ -3,24 +3,26 @@ import NDK, {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKList,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKUser
|
||||
NDKUser,
|
||||
zapInvoiceFromEvent
|
||||
} 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, UserProfile } from 'types'
|
||||
import { ModDetails, MuteLists, UserProfile, UserRelaysType } from 'types'
|
||||
import {
|
||||
constructModListFromEvents,
|
||||
hexToNpub,
|
||||
log,
|
||||
LogType,
|
||||
npubToHex,
|
||||
orderEventsChronologically
|
||||
orderEventsChronologically,
|
||||
timeout
|
||||
} from 'utils'
|
||||
|
||||
type FetchModsOptions = {
|
||||
@ -28,28 +30,40 @@ type FetchModsOptions = {
|
||||
until?: number
|
||||
since?: number
|
||||
limit?: number
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface NDKContextType {
|
||||
export interface NDKContextType {
|
||||
ndk: NDK
|
||||
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
|
||||
fetchEvents: (filter: NDKFilter, relayUrls?: string[]) => Promise<NDKEvent[]>
|
||||
fetchEvent: (
|
||||
filter: NDKFilter,
|
||||
relayUrls?: string[]
|
||||
) => Promise<NDKEvent | null>
|
||||
|
||||
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
|
||||
fetchEvent: (filter: NDKFilter) => Promise<NDKEvent | null>
|
||||
fetchEventsFromUserRelays: (
|
||||
filter: NDKFilter,
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => Promise<NDKEvent[]>
|
||||
fetchEventFromUserRelays: (
|
||||
filter: NDKFilter,
|
||||
filter: NDKFilter | 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`
|
||||
@ -72,6 +86,31 @@ 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' })
|
||||
@ -88,6 +127,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
],
|
||||
cacheAdapter: dexieAdapter
|
||||
})
|
||||
addAdminRelays(ndk)
|
||||
|
||||
ndk.connect()
|
||||
|
||||
@ -108,42 +148,17 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
source,
|
||||
until,
|
||||
since,
|
||||
limit
|
||||
limit,
|
||||
author
|
||||
}: 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
|
||||
since, // Optional filter to fetch events from this timestamp
|
||||
authors: author ? [author] : undefined // Optional filter to fetch events from only this author
|
||||
}
|
||||
|
||||
// If the source matches the current window location, add a filter condition
|
||||
@ -152,11 +167,10 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
|
||||
return ndk
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
|
||||
NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true)
|
||||
)
|
||||
.fetchEvents(filter, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
orderEventsChronologically(ndkEvents)
|
||||
@ -179,56 +193,17 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Asynchronously retrieves multiple event based on a provided filter.
|
||||
*
|
||||
* @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,
|
||||
relayUrls: string[] = []
|
||||
): Promise<NDKEvent[]> => {
|
||||
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)
|
||||
|
||||
const fetchEvents = async (filter: NDKFilter): Promise<NDKEvent[]> => {
|
||||
return ndk
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
|
||||
NDKRelaySet.fromRelayUrls(Array.from(relays), ndk, true)
|
||||
)
|
||||
.fetchEvents(filter, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
return orderEventsChronologically(ndkEvents)
|
||||
@ -242,15 +217,13 @@ 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.
|
||||
* Asynchronously retrieves an event based on a provided filter.
|
||||
*
|
||||
* @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)
|
||||
const fetchEvent = async (filter: NDKFilter) => {
|
||||
const events = await fetchEvents(filter)
|
||||
if (events.length === 0) return null
|
||||
return events[0]
|
||||
}
|
||||
@ -265,12 +238,15 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
* @returns A promise that resolves with an array of events.
|
||||
*/
|
||||
const fetchEventsFromUserRelays = async (
|
||||
filter: NDKFilter,
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => {
|
||||
// Find the user's relays.
|
||||
const relayUrls = await getRelayListForUser(hexKey, ndk)
|
||||
): 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
|
||||
@ -285,8 +261,22 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
// Fetch the event from the user's relays using the provided filter and relay URLs
|
||||
return fetchEvents(filter, relayUrls)
|
||||
return ndk
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
|
||||
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
)
|
||||
.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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -299,7 +289,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,
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
) => {
|
||||
@ -329,6 +319,178 @@ 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={{
|
||||
@ -338,7 +500,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
fetchEventFromUserRelays,
|
||||
findMetadata
|
||||
findMetadata,
|
||||
getTotalZapAmount,
|
||||
publish,
|
||||
getNSFWList,
|
||||
getMuteLists
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -1,3 +1 @@
|
||||
export * from './metadata'
|
||||
export * from './relay'
|
||||
export * from './zap'
|
||||
|
@ -1,217 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,561 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,6 @@ import {
|
||||
ZapRequest
|
||||
} from '../types'
|
||||
import { log, LogType, npubToHex } from '../utils'
|
||||
import { MetadataController, UserRelaysType } from './metadata'
|
||||
|
||||
/**
|
||||
* Singleton class to manage zap related operations.
|
||||
@ -48,6 +47,7 @@ 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,6 +59,7 @@ export class ZapController {
|
||||
lud16: string,
|
||||
amount: number,
|
||||
recipientPubKey: string,
|
||||
recipientRelays: string[],
|
||||
senderPubkey: string,
|
||||
content?: string,
|
||||
eventId?: string,
|
||||
@ -88,6 +89,7 @@ export class ZapController {
|
||||
amount,
|
||||
content,
|
||||
recipientPubKey,
|
||||
recipientRelays,
|
||||
senderPubkey,
|
||||
eventId,
|
||||
aTag
|
||||
@ -273,6 +275,7 @@ 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.
|
||||
@ -282,6 +285,7 @@ export class ZapController {
|
||||
amount: number,
|
||||
content = '',
|
||||
recipientPubKey: string,
|
||||
recipientRelays: string[],
|
||||
senderPubkey: string,
|
||||
eventId?: string,
|
||||
aTag?: string
|
||||
@ -290,21 +294,15 @@ export class ZapController {
|
||||
|
||||
if (!recipientHexKey) throw 'Invalid recipient pubKey.'
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
const receiverReadRelays = await metadataController.findUserRelays(
|
||||
recipientHexKey,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
if (!receiverReadRelays.includes(this.appRelay)) {
|
||||
receiverReadRelays.push(this.appRelay)
|
||||
if (!recipientRelays.includes(this.appRelay)) {
|
||||
recipientRelays.push(this.appRelay)
|
||||
}
|
||||
|
||||
const zapRequest: ZapRequest = {
|
||||
kind: kinds.ZapRequest,
|
||||
content,
|
||||
tags: [
|
||||
['relays', ...receiverReadRelays],
|
||||
['relays', ...recipientRelays],
|
||||
['amount', `${amount}`],
|
||||
['p', recipientHexKey]
|
||||
],
|
||||
|
@ -6,3 +6,5 @@ export * from './useMuteLists'
|
||||
export * from './useNSFWList'
|
||||
export * from './useReactions'
|
||||
export * from './useNDKContext'
|
||||
export * from './useScrollDisable'
|
||||
export * from './useLocalStorage'
|
||||
|
@ -6,22 +6,33 @@ import {
|
||||
NDKSubscription,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { UserRelaysType } from 'controllers'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CommentEvent, ModDetails } from 'types'
|
||||
import { log, LogType } from 'utils'
|
||||
import { CommentEvent, UserRelaysType } from 'types'
|
||||
import { log, LogType, timeout } from 'utils'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useComments = (mod: ModDetails) => {
|
||||
export const useComments = (
|
||||
author: string | undefined,
|
||||
aTag: string | undefined
|
||||
) => {
|
||||
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 getRelayListForUser(mod.author, ndk)
|
||||
|
||||
const authorReadRelays = await Promise.race([
|
||||
getRelayListForUser(author, ndk),
|
||||
timeout(10 * 1000) // add a 10 sec timeout
|
||||
])
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[UserRelaysType.Read]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
@ -30,7 +41,7 @@ export const useComments = (mod: ModDetails) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`,
|
||||
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
@ -38,16 +49,24 @@ export const useComments = (mod: ModDetails) => {
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
'#a': [mod.aTag]
|
||||
'#a': [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(authorReadRelays, ndk, true)
|
||||
NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
|
||||
)
|
||||
|
||||
subscription.on('event', (ndkEvent) => {
|
||||
@ -81,7 +100,7 @@ export const useComments = (mod: ModDetails) => {
|
||||
subscription.stop()
|
||||
}
|
||||
}
|
||||
}, [mod.aTag, mod.author, ndk])
|
||||
}, [aTag, author, ndk])
|
||||
|
||||
return {
|
||||
commentEvents,
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from 'types'
|
||||
import { npubToHex } from 'utils'
|
||||
|
||||
export const useFilteredMods = (
|
||||
mods: ModDetails[],
|
||||
@ -17,10 +18,20 @@ 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:
|
||||
@ -38,11 +49,15 @@ 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"
|
||||
if (!(isAdmin && isUnmoderatedFully)) {
|
||||
// Allow "Unmoderated Fully" when author visits own profile
|
||||
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.admin.authors.includes(mod.author) &&
|
||||
@ -70,6 +85,7 @@ export const useFilteredMods = (
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
filterOptions.nsfw,
|
||||
author,
|
||||
mods,
|
||||
muteLists,
|
||||
nsfwList
|
||||
|
50
src/hooks/useLocalStorage.tsx
Normal file
50
src/hooks/useLocalStorage.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
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]
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MuteLists } from 'types'
|
||||
import { useAppSelector } from './redux'
|
||||
import { MetadataController } from 'controllers'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useMuteLists = () => {
|
||||
const { getMuteLists } = useNDKContext()
|
||||
const [muteLists, setMuteLists] = useState<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
@ -21,17 +22,11 @@ export const useMuteLists = () => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
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])
|
||||
const pubkey = userState.user?.pubkey as string | undefined
|
||||
getMuteLists(pubkey).then((lists) => {
|
||||
setMuteLists(lists)
|
||||
})
|
||||
}, [userState, getMuteLists])
|
||||
|
||||
return muteLists
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { NDKContext } from 'contexts/NDKContext'
|
||||
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useNDKContext = () => {
|
||||
@ -9,23 +9,5 @@ export const useNDKContext = () => {
|
||||
'NDKContext should not be used in out component tree hierarchy'
|
||||
)
|
||||
|
||||
const {
|
||||
ndk,
|
||||
fetchEvents,
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
fetchEventFromUserRelays,
|
||||
fetchMods,
|
||||
findMetadata
|
||||
} = ndkContext
|
||||
|
||||
return {
|
||||
ndk,
|
||||
fetchEvents,
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
fetchEventFromUserRelays,
|
||||
fetchMods,
|
||||
findMetadata
|
||||
}
|
||||
return { ...ndkContext } as NDKContextType
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
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 () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
metadataController.getNSFWList().then((list) => {
|
||||
getNSFWList().then((list) => {
|
||||
setNSFWList(list)
|
||||
})
|
||||
})
|
||||
|
18
src/hooks/useProfile.tsx
Normal file
18
src/hooks/useProfile.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
@ -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 { abbreviateNumber, log, LogType, now } from 'utils'
|
||||
import { UserRelaysType } from 'types'
|
||||
import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
|
||||
|
||||
type UseReactionsParams = {
|
||||
pubkey: string
|
||||
@ -14,7 +14,7 @@ type UseReactionsParams = {
|
||||
}
|
||||
|
||||
export const useReactions = (params: UseReactionsParams) => {
|
||||
const { ndk, fetchEventsFromUserRelays } = useNDKContext()
|
||||
const { ndk, fetchEventsFromUserRelays, publish } = useNDKContext()
|
||||
const [isReactionInProgress, setIsReactionInProgress] = useState(false)
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false)
|
||||
const [reactionEvents, setReactionEvents] = useState<NDKEvent[]>([])
|
||||
@ -32,7 +32,11 @@ export const useReactions = (params: UseReactionsParams) => {
|
||||
filter['#e'] = [params.eTag]
|
||||
}
|
||||
|
||||
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
|
||||
// 1 minute timeout
|
||||
Promise.race([
|
||||
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
|
||||
timeout(60000)
|
||||
])
|
||||
.then((events) => {
|
||||
setReactionEvents(events)
|
||||
})
|
||||
@ -119,13 +123,11 @@ export const useReactions = (params: UseReactionsParams) => {
|
||||
|
||||
if (!signedEvent) return
|
||||
|
||||
setReactionEvents((prev) => [...prev, new NDKEvent(ndk, signedEvent)])
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event,
|
||||
params.pubkey,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
setReactionEvents((prev) => [...prev, ndkEvent])
|
||||
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
log(
|
||||
|
11
src/hooks/useScrollDisable.ts
Normal file
11
src/hooks/useScrollDisable.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useBodyScrollDisable = (disable: boolean) => {
|
||||
useEffect(() => {
|
||||
if (disable) document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [disable])
|
||||
}
|
10
src/layout/feed.tsx
Normal file
10
src/layout/feed.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export const FeedLayout = () => {
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<h1>WIP</h1>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -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,6 +27,18 @@ 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,
|
||||
@ -66,6 +78,7 @@ export const Header = () => {
|
||||
}, [dispatch, findMetadata])
|
||||
|
||||
const handleLogin = () => {
|
||||
handleOpen()
|
||||
launchNostrLoginDialog()
|
||||
}
|
||||
|
||||
@ -199,7 +212,7 @@ export const Header = () => {
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.blog}
|
||||
to={appRoutes.blogs}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Blog
|
||||
@ -260,9 +273,11 @@ const TipButtonWithDialog = React.memo(() => {
|
||||
const [adminNpub, setAdminNpub] = useState<string | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
setAdminNpub(metadataController.adminNpubs[0])
|
||||
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
setAdminNpub(adminNpubs[0])
|
||||
})
|
||||
|
||||
return (
|
||||
@ -321,6 +336,8 @@ const TipButtonWithDialog = React.memo(() => {
|
||||
const RegisterButtonWithDialog = () => {
|
||||
const [showPopUp, setShowPopUp] = useState(false)
|
||||
|
||||
useBodyScrollDisable(showPopUp)
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
@ -362,16 +379,14 @@ const RegisterButtonWithDialog = () => {
|
||||
Browser Extensions (Windows)
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Outlet, ScrollRestoration } from 'react-router-dom'
|
||||
import { Footer } from './footer'
|
||||
import { Header } from './header'
|
||||
import { SocialNav } from './socialNav'
|
||||
@ -12,6 +12,7 @@ export const Layout = () => {
|
||||
<Outlet />
|
||||
<Footer />
|
||||
<SocialNav />
|
||||
<ScrollRestoration />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,24 +1,32 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { NavLink, NavLinkProps } 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'
|
||||
@ -32,30 +40,27 @@ export const SocialNav = () => {
|
||||
<div className='socialNavInside'>
|
||||
<NavButton
|
||||
to={appRoutes.home}
|
||||
isActive={isOnHomePage}
|
||||
viewBox="0 -32 576 576"
|
||||
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.home}
|
||||
isActive={false}
|
||||
to={appRoutes.feed}
|
||||
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.home}
|
||||
isActive={false}
|
||||
to={appRoutes.notifications}
|
||||
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'
|
||||
/>
|
||||
<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'
|
||||
/>
|
||||
{!!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'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='socialNavCollapse' onClick={toggleNav}>
|
||||
@ -78,19 +83,23 @@ export const SocialNav = () => {
|
||||
)
|
||||
}
|
||||
|
||||
interface NavButtonProps {
|
||||
to: string
|
||||
isActive: boolean
|
||||
interface NavButtonProps extends NavLinkProps {
|
||||
svgPath: string
|
||||
viewBox?: string
|
||||
}
|
||||
|
||||
const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButtonProps) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`btn btnMain socialNavInsideBtn ${
|
||||
isActive ? 'socialNavInsideBtnActive' : ''
|
||||
}`}
|
||||
const NavButton = ({
|
||||
svgPath,
|
||||
viewBox = '0 0 512 512',
|
||||
...rest
|
||||
}: NavButtonProps) => (
|
||||
<NavLink
|
||||
{...rest}
|
||||
className={({ isActive }) =>
|
||||
`btn btnMain socialNavInsideBtn ${
|
||||
isActive ? 'socialNavInsideBtnActive' : ''
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -101,7 +110,5 @@ const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButton
|
||||
>
|
||||
<path d={svgPath}></path>
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
|
||||
|
||||
</NavLink>
|
||||
)
|
||||
|
11
src/main.tsx
11
src/main.tsx
@ -1,7 +1,6 @@
|
||||
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'
|
||||
@ -12,12 +11,10 @@ import { NDKContextProvider } from 'contexts/NDKContext.tsx'
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
</NDKContextProvider>
|
||||
<ToastContainer />
|
||||
</HashRouter>
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
</NDKContextProvider>
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
40
src/pages/404.tsx
Normal file
40
src/pages/404.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
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>
|
||||
)
|
||||
}
|
264
src/pages/blog/action.ts
Normal file
264
src/pages/blog/action.ts
Normal file
@ -0,0 +1,264 @@
|
||||
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
|
||||
}
|
323
src/pages/blog/index.tsx
Normal file
323
src/pages/blog/index.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
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>
|
||||
)
|
||||
}
|
233
src/pages/blog/loader.ts
Normal file
233
src/pages/blog/loader.ts
Normal file
@ -0,0 +1,233 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
92
src/pages/blog/report.tsx
Normal file
92
src/pages/blog/report.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
146
src/pages/blog/reportAction.ts
Normal file
146
src/pages/blog/reportAction.ts
Normal file
@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
208
src/pages/blogs/index.tsx
Normal file
208
src/pages/blogs/index.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
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>
|
||||
)
|
||||
}
|
68
src/pages/blogs/loader.ts
Normal file
68
src/pages/blogs/loader.ts
Normal file
@ -0,0 +1,68 @@
|
||||
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 []
|
||||
}
|
||||
}
|
3
src/pages/feed.tsx
Normal file
3
src/pages/feed.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const FeedPage = () => {
|
||||
return <h2>Feed</h2>
|
||||
}
|
@ -6,46 +6,98 @@ 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, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { FilterOptions, ModDetails } from 'types'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from 'types'
|
||||
import { extractModData, isModDataComplete } from 'utils'
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
extractModData,
|
||||
isModDataComplete,
|
||||
scrollIntoView
|
||||
} 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, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
})
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const filteredMods = useFilteredMods(
|
||||
mods,
|
||||
// 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,
|
||||
userState,
|
||||
filterOptions,
|
||||
nsfwList,
|
||||
@ -53,14 +105,15 @@ export const GamePage = () => {
|
||||
)
|
||||
|
||||
// Pagination logic
|
||||
const totalGames = filteredMods.length
|
||||
const totalGames = filteredModList.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 = filteredMods.slice(startIndex, endIndex)
|
||||
const currentMods = filteredModList.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
@ -102,7 +155,10 @@ export const GamePage = () => {
|
||||
<>
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
@ -111,14 +167,24 @@ export const GamePage = () => {
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{gameName}
|
||||
</span>
|
||||
{searchTerm !== '' && (
|
||||
<>
|
||||
—
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{searchTerm}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<SearchInput
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
ref={searchTermRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModFilter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<ModFilter />
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{currentMods.map((mod) => (
|
||||
|
@ -8,8 +8,11 @@ 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)
|
||||
@ -63,6 +66,7 @@ export const GamesPage = () => {
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
@ -71,8 +75,8 @@ export const GamesPage = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
searchTerm: value,
|
||||
searching: 'Games'
|
||||
q: value,
|
||||
kind: 'Games'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
@ -88,40 +92,20 @@ export const GamesPage = () => {
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Games</h2>
|
||||
</div>
|
||||
<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>
|
||||
<SearchInput
|
||||
ref={searchTermRef}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
|
@ -1,22 +1,30 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { kinds, nip19 } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate, useNavigation } 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 } from '../constants'
|
||||
import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants'
|
||||
import {
|
||||
useDidMount,
|
||||
useGames,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
} from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import { ModDetails } from '../types'
|
||||
import { extractModData, handleModImageError, log, LogType } from '../utils'
|
||||
import { BlogCardDetails, ModDetails, NSFWFilter, SortBy } from '../types'
|
||||
import {
|
||||
extractBlogCardDetails,
|
||||
extractModData,
|
||||
handleModImageError,
|
||||
log,
|
||||
LogType,
|
||||
npubToHex
|
||||
} from '../utils'
|
||||
|
||||
import '../styles/cardLists.css'
|
||||
import '../styles/SimpleSlider.css'
|
||||
@ -27,7 +35,8 @@ import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { Spinner } from 'components/Spinner'
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -114,27 +123,7 @@ export const HomePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<DisplayLatestMods />
|
||||
<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>
|
||||
<DisplayLatestBlogs />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -152,7 +141,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => {
|
||||
|
||||
useDidMount(() => {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey, relays = [] } = decoded.data
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
const ndkFilter: NDKFilter = {
|
||||
'#a': [identifier],
|
||||
@ -160,7 +149,7 @@ const SlideContent = ({ naddr }: SlideContentProps) => {
|
||||
kinds: [kind]
|
||||
}
|
||||
|
||||
fetchEvent(ndkFilter, relays)
|
||||
fetchEvent(ndkFilter)
|
||||
.then((ndkEvent) => {
|
||||
if (ndkEvent) {
|
||||
const extracted = extractModData(ndkEvent)
|
||||
@ -225,7 +214,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
|
||||
|
||||
useDidMount(() => {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey, relays = [] } = decoded.data
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
const ndkFilter: NDKFilter = {
|
||||
'#a': [identifier],
|
||||
@ -233,7 +222,7 @@ const DisplayMod = ({ naddr }: DisplayModProps) => {
|
||||
kinds: [kind]
|
||||
}
|
||||
|
||||
fetchEvent(ndkFilter, relays)
|
||||
fetchEvent(ndkFilter)
|
||||
.then((ndkEvent) => {
|
||||
if (ndkEvent) {
|
||||
const extracted = extractModData(ndkEvent)
|
||||
@ -267,6 +256,8 @@ 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(() => {
|
||||
@ -320,10 +311,136 @@ const DisplayLatestMods = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Spinner = () => {
|
||||
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()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='spinner'>
|
||||
<div className='spinnerCircle'></div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -2,17 +2,21 @@ 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 { nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { kinds, 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 { MetadataController, UserRelaysType } from '../../controllers'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from '../../hooks'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
} from '../../hooks'
|
||||
import { getGamePageRoute, getModsEditPageRoute } from '../../routes'
|
||||
import '../../styles/comments.css'
|
||||
import '../../styles/downloads.css'
|
||||
@ -24,9 +28,13 @@ import '../../styles/styles.css'
|
||||
import '../../styles/tabs.css'
|
||||
import '../../styles/tags.css'
|
||||
import '../../styles/write.css'
|
||||
import { DownloadUrl, ModDetails } from '../../types'
|
||||
import {
|
||||
abbreviateNumber,
|
||||
BlogCardDetails,
|
||||
DownloadUrl,
|
||||
ModDetails,
|
||||
UserRelaysType
|
||||
} from '../../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
downloadFile,
|
||||
extractModData,
|
||||
@ -38,22 +46,31 @@ import {
|
||||
sendDMUsingRandomKey,
|
||||
signAndPublish
|
||||
} from '../../utils'
|
||||
import { Comments } from './internal/comment'
|
||||
import { Reactions } from './internal/reactions'
|
||||
import { Zap } from './internal/zap'
|
||||
import placeholder from '../../assets/img/DEGMods Placeholder Img.png'
|
||||
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'
|
||||
|
||||
export const ModPage = () => {
|
||||
const { naddr } = useParams()
|
||||
const { fetchEvent } = useNDKContext()
|
||||
const [modData, setModData] = useState<ModDetails>()
|
||||
const [mod, setMod] = 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, relays = [] } = decoded.data
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
const filter: NDKFilter = {
|
||||
'#a': [identifier],
|
||||
@ -61,11 +78,11 @@ export const ModPage = () => {
|
||||
kinds: [kind]
|
||||
}
|
||||
|
||||
fetchEvent(filter, relays)
|
||||
fetchEvent(filter)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
const extracted = extractModData(event)
|
||||
setModData(extracted)
|
||||
setMod(extracted)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@ -129,7 +146,7 @@ export const ModPage = () => {
|
||||
nsfw={modData.nsfw}
|
||||
/>
|
||||
<Interactions
|
||||
modDetails={modData}
|
||||
addressable={modData}
|
||||
commentCount={commentCount}
|
||||
/>
|
||||
<PublishDetails
|
||||
@ -177,21 +194,10 @@ export const ModPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<DisplayModAuthorBlogs />
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<Comments
|
||||
modDetails={modData}
|
||||
addressable={modData}
|
||||
setCommentCount={setCommentCount}
|
||||
/>
|
||||
</div>
|
||||
@ -212,7 +218,7 @@ type GameProps = {
|
||||
}
|
||||
|
||||
const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
||||
const { fetchEventFromUserRelays } = useNDKContext()
|
||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@ -221,6 +227,8 @@ 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
|
||||
@ -343,7 +351,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
||||
|
||||
setLoadingSpinnerDesc('Updating mute list event')
|
||||
|
||||
const isUpdated = await signAndPublish(unsignedEvent)
|
||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
||||
if (isUpdated) {
|
||||
setIsBlocked(true)
|
||||
}
|
||||
@ -384,7 +392,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Updating mute list event')
|
||||
const isUpdated = await signAndPublish(unsignedEvent)
|
||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
||||
if (isUpdated) {
|
||||
setIsBlocked(false)
|
||||
}
|
||||
@ -450,7 +458,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
||||
|
||||
setLoadingSpinnerDesc('Updating nsfw list event')
|
||||
|
||||
const isUpdated = await signAndPublish(unsignedEvent)
|
||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
||||
if (isUpdated) {
|
||||
setIsAddedToNSFW(true)
|
||||
}
|
||||
@ -491,7 +499,7 @@ const Game = ({ naddr, game, author, aTag }: GameProps) => {
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Updating nsfw list event')
|
||||
const isUpdated = await signAndPublish(unsignedEvent)
|
||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
||||
if (isUpdated) {
|
||||
setIsAddedToNSFW(false)
|
||||
}
|
||||
@ -660,18 +668,25 @@ 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 { fetchEventFromUserRelays } = useNDKContext()
|
||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [selectedOptions, setSelectedOptions] = useState({
|
||||
actuallyCP: false,
|
||||
spam: false,
|
||||
scam: false,
|
||||
notAGameMod: false,
|
||||
stolenGameMod: false,
|
||||
wasntTaggedNSFW: false,
|
||||
otherReason: false
|
||||
})
|
||||
const [selectedOptions, setSelectedOptions] = useState(
|
||||
MOD_REPORT_REASONS.reduce((acc: { [key: string]: boolean }, cur) => {
|
||||
acc[cur.key] = false
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
@ -708,8 +723,8 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
const reportingPubkey = npubToHex(metadataController.reportingNpub)
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
const reportingPubkey = npubToHex(reportingNpub)
|
||||
|
||||
if (reportingPubkey === hexPubkey) {
|
||||
setLoadingSpinnerDesc(`Finding user's mute list`)
|
||||
@ -760,7 +775,7 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Updating mute list event')
|
||||
const isUpdated = await signAndPublish(unsignedEvent)
|
||||
const isUpdated = await signAndPublish(unsignedEvent, ndk, publish)
|
||||
if (isUpdated) handleClose()
|
||||
} else {
|
||||
const href = window.location.href
|
||||
@ -773,7 +788,12 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
|
||||
})
|
||||
|
||||
setLoadingSpinnerDesc('Sending report')
|
||||
const isSent = await sendDMUsingRandomKey(message, reportingPubkey!)
|
||||
const isSent = await sendDMUsingRandomKey(
|
||||
message,
|
||||
reportingPubkey!,
|
||||
ndk,
|
||||
publish
|
||||
)
|
||||
if (isSent) handleClose()
|
||||
}
|
||||
setIsLoading(false)
|
||||
@ -812,86 +832,15 @@ const ReportPopup = ({ aTag, handleClose }: ReportPopupProps) => {
|
||||
>
|
||||
Why are you reporting this?
|
||||
</label>
|
||||
<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')}
|
||||
{MOD_REPORT_REASONS.map((r) => (
|
||||
<CheckboxField
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
name={r.key}
|
||||
isChecked={selectedOptions[r.key]}
|
||||
handleChange={() => handleCheckboxChange(r.key)}
|
||||
/>
|
||||
</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'
|
||||
@ -1015,126 +964,6 @@ 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,
|
||||
@ -1392,3 +1221,49 @@ 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>
|
||||
)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { MOD_FILTER_LIMIT } from '../constants'
|
||||
import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
@ -17,24 +18,20 @@ import '../styles/filters.css'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
import '../styles/styles.css'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from '../types'
|
||||
import { FilterOptions, ModDetails } from '../types'
|
||||
import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils'
|
||||
import { SearchInput } from 'components/SearchInput'
|
||||
|
||||
export const ModsPage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
const { fetchMods } = useNDKContext()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
})
|
||||
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
|
||||
@ -66,6 +63,7 @@ export const ModsPage = () => {
|
||||
.then((res) => {
|
||||
setMods(res)
|
||||
setPage((prev) => prev + 1)
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
@ -84,6 +82,7 @@ export const ModsPage = () => {
|
||||
.then((res) => {
|
||||
setMods(res)
|
||||
setPage((prev) => prev - 1)
|
||||
scrollIntoView(scrollTargetRef.current)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
@ -103,12 +102,12 @@ export const ModsPage = () => {
|
||||
{isFetching && <LoadingSpinner desc='Fetching mod details from relays' />}
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<PageTitleRow />
|
||||
<ModFilter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<ModFilter />
|
||||
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
@ -139,8 +138,8 @@ const PageTitleRow = React.memo(() => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
searchTerm: value,
|
||||
searching: 'Mods'
|
||||
q: value,
|
||||
kind: 'Mods'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
@ -159,35 +158,11 @@ const PageTitleRow = React.memo(() => {
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Mods</h2>
|
||||
</div>
|
||||
<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>
|
||||
<SearchInput
|
||||
ref={searchTermRef}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
3
src/pages/notifications.tsx
Normal file
3
src/pages/notifications.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const NotificationsPage = () => {
|
||||
return <h2>Notifications</h2>
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export const ProfilePage = () => {
|
||||
return <h1>WIP</h1>
|
||||
}
|
845
src/pages/profile/index.tsx
Normal file
845
src/pages/profile/index.tsx
Normal file
@ -0,0 +1,845 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
147
src/pages/profile/loader.ts
Normal file
147
src/pages/profile/loader.ts
Normal file
@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
@ -4,15 +4,16 @@ 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,
|
||||
@ -22,28 +23,20 @@ import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useGames,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
} from 'hooks'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
MuteLists,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from 'types'
|
||||
import { extractModData, isModDataComplete, log, LogType } from 'utils'
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
extractModData,
|
||||
isModDataComplete,
|
||||
scrollIntoView
|
||||
} from 'utils'
|
||||
|
||||
enum SearchKindEnum {
|
||||
Mods = 'Mods',
|
||||
@ -52,30 +45,36 @@ enum SearchKindEnum {
|
||||
}
|
||||
|
||||
export const SearchPage = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [searchKind, setSearchKind] = useState(
|
||||
(searchParams.get('searching') as SearchKindEnum) || SearchKindEnum.Mods
|
||||
const searchKind =
|
||||
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
|
||||
|
||||
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 [searchTerm, setSearchTerm] = useState(
|
||||
searchParams.get('searchTerm') || ''
|
||||
)
|
||||
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
|
||||
@ -88,7 +87,10 @@ export const SearchPage = () => {
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div
|
||||
className='IBMSecMainGroup IBMSecMainGroupAlt'
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
@ -99,48 +101,21 @@ export const SearchPage = () => {
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<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>
|
||||
<SearchInput
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
ref={searchTermRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Filters
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
searchKind={searchKind}
|
||||
setSearchKind={setSearchKind}
|
||||
/>
|
||||
<Filters />
|
||||
{searchKind === SearchKindEnum.Mods && (
|
||||
<ModsResult
|
||||
searchTerm={searchTerm}
|
||||
filterOptions={filterOptions}
|
||||
muteLists={muteLists}
|
||||
nsfwList={nsfwList}
|
||||
el={scrollTargetRef.current}
|
||||
/>
|
||||
)}
|
||||
{searchKind === SearchKindEnum.Users && (
|
||||
@ -159,73 +134,29 @@ export const SearchPage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
type FiltersProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
searchKind: SearchKindEnum
|
||||
setSearchKind: Dispatch<SetStateAction<SearchKindEnum>>
|
||||
}
|
||||
const Filters = React.memo(() => {
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
const Filters = React.memo(
|
||||
({
|
||||
filterOptions,
|
||||
setFilterOptions,
|
||||
searchKind,
|
||||
setSearchKind
|
||||
}: FiltersProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
{searchKind === SearchKindEnum.Mods && <ModFilter />}
|
||||
|
||||
{searchKind === SearchKindEnum.Users && (
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
@ -234,26 +165,65 @@ const Filters = React.memo(
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Searching: {searchKind}
|
||||
{filterOptions.moderated}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SearchKindEnum).map((item, index) => (
|
||||
<div
|
||||
key={`searchingFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() => setSearchKind(item)}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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
|
||||
@ -263,13 +233,15 @@ type ModsResultProps = {
|
||||
user: MuteLists
|
||||
}
|
||||
nsfwList: string[]
|
||||
el: HTMLElement | null
|
||||
}
|
||||
|
||||
const ModsResult = ({
|
||||
filterOptions,
|
||||
searchTerm,
|
||||
muteLists,
|
||||
nsfwList
|
||||
nsfwList,
|
||||
el
|
||||
}: ModsResultProps) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
@ -305,10 +277,13 @@ 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()
|
||||
@ -322,8 +297,16 @@ const ModsResult = ({
|
||||
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
||||
) > -1
|
||||
|
||||
return mods.filter(filterFn)
|
||||
}, [mods, searchTerm])
|
||||
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])
|
||||
|
||||
const filteredModList = useFilteredMods(
|
||||
filteredMods,
|
||||
@ -334,10 +317,12 @@ const ModsResult = ({
|
||||
)
|
||||
|
||||
const handleNext = () => {
|
||||
scrollIntoView(el)
|
||||
setPage((prev) => prev + 1)
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
scrollIntoView(el)
|
||||
setPage((prev) => prev - 1)
|
||||
}
|
||||
|
||||
@ -376,39 +361,70 @@ const UsersResult = ({
|
||||
moderationFilter,
|
||||
muteLists
|
||||
}: UsersResultProps) => {
|
||||
const { fetchEvents } = useNDKContext()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const { ndk } = useNDKContext()
|
||||
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm === '') {
|
||||
setProfiles([])
|
||||
} else {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Metadata],
|
||||
search: searchTerm
|
||||
const sub = ndk.subscribe(
|
||||
{
|
||||
kinds: [NDKKind.Metadata],
|
||||
search: searchTerm
|
||||
},
|
||||
{
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
},
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
setProfiles(profiles)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
// Clear previous results
|
||||
const events = new Map<string, NDKEvent>()
|
||||
|
||||
// Bind handler and start the sub
|
||||
sub.on('event', onEvent)
|
||||
sub.start()
|
||||
return () => {
|
||||
sub.stop()
|
||||
}
|
||||
}
|
||||
}, [searchTerm, fetchEvents])
|
||||
}, [ndk, searchTerm])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = [...profiles]
|
||||
@ -433,25 +449,13 @@ 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}
|
||||
displayName={displayName}
|
||||
about={about}
|
||||
image={profile?.image}
|
||||
nip05={profile?.nip05}
|
||||
lud16={profile?.lud16}
|
||||
/>
|
||||
<Profile pubkey={profile.pubkey as string} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@ -519,3 +523,11 @@ 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
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
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'
|
||||
@ -57,15 +56,12 @@ const SettingTabs = () => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
MetadataController.getInstance().then((controller) => {
|
||||
if (userState.auth && userState.user?.npub) {
|
||||
setIsAdmin(
|
||||
controller.adminNpubs.includes(userState.user.npub as string)
|
||||
)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}, [userState])
|
||||
|
||||
const handleSignOut = () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { InputField } from 'components/Inputs'
|
||||
import { ProfileQRButtonWithPopUp } from 'components/ProfileSection'
|
||||
import { useAppDispatch, useAppSelector } from 'hooks'
|
||||
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
|
||||
import { kinds, nip19, UnsignedEvent, Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
@ -14,7 +14,6 @@ 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'
|
||||
@ -43,6 +42,7 @@ 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) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
nprofile = nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
profileRoute = getProfilePageRoute(nprofile)
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,9 +163,8 @@ export const ProfileSettings = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -248,10 +247,8 @@ 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 userState.user?.pubkey === 'string' && (
|
||||
<ProfileQRButtonWithPopUp
|
||||
pubkey={userState.user.pubkey}
|
||||
/>
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,21 +1,23 @@
|
||||
import { NDKRelayList } from '@nostr-dev-kit/ndk'
|
||||
import {
|
||||
getRelayListForUser,
|
||||
NDKEvent,
|
||||
NDKRelayList,
|
||||
NDKRelayStatus
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { InputField } from 'components/Inputs'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import {
|
||||
MetadataController,
|
||||
RelayController,
|
||||
UserRelaysType
|
||||
} from 'controllers'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import { useAppSelector, useDidMount, useNDKContext } 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)
|
||||
@ -23,10 +25,8 @@ export const RelaySettings = () => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRelayList = async (pubkey: string) => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController
|
||||
.getNDKRelayList(pubkey)
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
getRelayListForUser(userState.user.pubkey as string, ndk)
|
||||
.then((res) => {
|
||||
setNDKRelayList(res)
|
||||
})
|
||||
@ -38,14 +38,10 @@ export const RelaySettings = () => {
|
||||
)
|
||||
setNDKRelayList(null)
|
||||
})
|
||||
}
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
fetchRelayList(userState.user.pubkey as string)
|
||||
} else {
|
||||
setNDKRelayList(null)
|
||||
}
|
||||
}, [userState])
|
||||
}, [userState, ndk])
|
||||
|
||||
const handleAdd = async (relayUrl: string) => {
|
||||
if (!ndkRelayList) return
|
||||
@ -78,11 +74,8 @@ export const RelaySettings = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays =
|
||||
await RelayController.getInstance().publishOnRelays(
|
||||
signedEvent,
|
||||
ndkRelayList.writeRelayUrls
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -140,11 +133,8 @@ export const RelaySettings = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays =
|
||||
await RelayController.getInstance().publishOnRelays(
|
||||
signedEvent,
|
||||
ndkRelayList.writeRelayUrls
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -214,11 +204,8 @@ export const RelaySettings = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedOnRelays =
|
||||
await RelayController.getInstance().publishOnRelays(
|
||||
signedEvent,
|
||||
ndkRelayList.writeRelayUrls
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -382,17 +369,29 @@ const RelayListItem = ({
|
||||
changeRelayType
|
||||
}: RelayItemProps) => {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const { ndk } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.connectRelay(relayUrl)
|
||||
.then((relay) => {
|
||||
if (relay && relay.connected) {
|
||||
setIsConnected(true)
|
||||
} else {
|
||||
setIsConnected(false)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -29,7 +29,7 @@ export const SubmitModPage = () => {
|
||||
useDidMount(async () => {
|
||||
if (naddr) {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey, relays = [] } = decoded.data
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
const filter: NDKFilter = {
|
||||
'#a': [identifier],
|
||||
@ -39,7 +39,7 @@ export const SubmitModPage = () => {
|
||||
|
||||
setIsFetching(true)
|
||||
|
||||
fetchEvent(filter, relays)
|
||||
fetchEvent(filter)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
const extracted = extractModData(event)
|
||||
|
@ -1,74 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
189
src/pages/write/action.tsx
Normal file
189
src/pages/write/action.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
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
|
||||
}
|
156
src/pages/write/index.tsx
Normal file
156
src/pages/write/index.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,25 +1,42 @@
|
||||
import { SearchPage } from 'pages/search'
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { Layout } from 'layout'
|
||||
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 { GamePage } from 'pages/game'
|
||||
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'
|
||||
|
||||
export const appRoutes = {
|
||||
index: '/',
|
||||
home: '/home',
|
||||
home: '/',
|
||||
games: '/games',
|
||||
game: '/game/:name',
|
||||
mods: '/mods',
|
||||
mod: '/mod/:naddr',
|
||||
about: '/about',
|
||||
blog: '/blog',
|
||||
blogs: '/blog',
|
||||
blog: '/blog/:naddr',
|
||||
blogEdit: '/blog/:naddr/edit',
|
||||
blogReport_actionOnly: '/blog/:naddr/report',
|
||||
submitMod: '/submit-mod',
|
||||
editMod: '/edit-mod/:naddr',
|
||||
write: '/write',
|
||||
@ -28,7 +45,9 @@ export const appRoutes = {
|
||||
settingsRelays: '/settings-relays',
|
||||
settingsPreferences: '/settings-preferences',
|
||||
settingsAdmin: '/settings-admin',
|
||||
profile: '/profile/:nprofile'
|
||||
profile: '/profile/:nprofile?',
|
||||
feed: '/feed',
|
||||
notifications: '/notifications'
|
||||
}
|
||||
|
||||
export const getGamePageRoute = (name: string) =>
|
||||
@ -40,76 +59,119 @@ 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 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 />
|
||||
}
|
||||
]
|
||||
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 />
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
@ -1,18 +1,19 @@
|
||||
.IBMSMSMSSS_Author {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 10px;
|
||||
border: solid 1px rgba(255,255,255,0.1);
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
@ -32,9 +33,11 @@
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.HBSS_Author_Top_NostrLinks {
|
||||
@ -127,7 +130,6 @@
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Left {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
@ -136,7 +138,6 @@
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.IBMSMSMSSS_Author_Top_Left {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 25px;
|
||||
|
@ -46,3 +46,20 @@
|
||||
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;
|
||||
}
|
||||
|
@ -96,6 +96,7 @@
|
||||
-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);
|
||||
@ -107,6 +108,7 @@
|
||||
-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;
|
||||
@ -119,11 +121,12 @@
|
||||
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 {
|
||||
@ -143,3 +146,21 @@
|
||||
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;
|
||||
}
|
||||
|
18
src/styles/dotsSpinner.module.scss
Normal file
18
src/styles/dotsSpinner.module.scss
Normal file
@ -0,0 +1,18 @@
|
||||
.loading::after {
|
||||
content: '.';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: '.\00a0\00a0';
|
||||
}
|
||||
40% {
|
||||
content: '..\00a0';
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
@ -229,3 +229,12 @@
|
||||
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;
|
||||
}
|
@ -22,6 +22,5 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -44,3 +44,13 @@
|
||||
cursor: default;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSTagsTag.IBMSMSMBSSTagsTagRepost {
|
||||
background: #ffffff1a;
|
||||
color: #ffffff59;
|
||||
font-weight: 700;
|
||||
border: unset;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
box-shadow: unset;
|
||||
}
|
42
src/types/blog.ts
Normal file
42
src/types/blog.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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
|
||||
}
|
@ -3,3 +3,4 @@ export * from './modsFilter'
|
||||
export * from './nostr'
|
||||
export * from './user'
|
||||
export * from './zap'
|
||||
export * from './blog'
|
||||
|
@ -7,3 +7,9 @@ export interface SignedEvent {
|
||||
id: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface Addressable {
|
||||
author: string
|
||||
id: string
|
||||
aTag: string
|
||||
}
|
||||
|
@ -1,3 +1,9 @@
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
|
||||
export type UserProfile = NDKUserProfile | null
|
||||
|
||||
export enum UserRelaysType {
|
||||
Read = 'readRelayUrls',
|
||||
Write = 'writeRelayUrls',
|
||||
Both = 'bothRelayUrls'
|
||||
}
|
||||
|
52
src/utils/blog.ts
Normal file
52
src/utils/blog.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
}
|
8
src/utils/consts.ts
Normal file
8
src/utils/consts.ts
Normal file
@ -0,0 +1,8 @@
|
||||
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
|
||||
}
|
@ -3,3 +3,6 @@ export * from './nostr'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zap'
|
||||
export * from './localStorage'
|
||||
export * from './consts'
|
||||
export * from './blog'
|
||||
|
32
src/utils/localStorage.ts
Normal file
32
src/utils/localStorage.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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 }))
|
||||
}
|
@ -9,9 +9,8 @@ import {
|
||||
UnsignedEvent
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { RelayController } from '../controllers'
|
||||
import { log, LogType } from './utils'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
|
||||
/**
|
||||
* Get the current time in seconds since the Unix epoch (January 1, 1970).
|
||||
@ -66,6 +65,38 @@ 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
|
||||
@ -123,7 +154,11 @@ 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) => {
|
||||
export const signAndPublish = async (
|
||||
unsignedEvent: UnsignedEvent,
|
||||
ndk: NDK,
|
||||
publish: (event: NDKEvent) => Promise<string[]>
|
||||
) => {
|
||||
// Sign the event. This returns a signed event or null if signing fails.
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
@ -138,11 +173,10 @@ export const signAndPublish = async (unsignedEvent: UnsignedEvent) => {
|
||||
// If the event couldn't be signed, exit the function and return null.
|
||||
if (!signedEvent) return false
|
||||
|
||||
// Publish the signed event to the relays using the RelayController.
|
||||
// Publish the signed event to the relays.
|
||||
// This returns an array of relay URLs where the event was successfully published.
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing to the relays failed
|
||||
if (publishedOnRelays.length === 0) {
|
||||
@ -170,7 +204,9 @@ export const signAndPublish = async (unsignedEvent: UnsignedEvent) => {
|
||||
*/
|
||||
export const sendDMUsingRandomKey = async (
|
||||
message: string,
|
||||
receiver: string
|
||||
receiver: string,
|
||||
ndk: NDK,
|
||||
publish: (event: NDKEvent) => Promise<string[]>
|
||||
) => {
|
||||
// Generate a random secret key for encrypting the message
|
||||
const secretKey = generateSecretKey()
|
||||
@ -201,11 +237,8 @@ export const sendDMUsingRandomKey = async (
|
||||
// Finalize and sign the event using the generated secret key
|
||||
const signedEvent = finalizeEvent(unsignedEvent, secretKey)
|
||||
|
||||
// Publish the signed event (the encrypted DM) to the relays
|
||||
const publishedOnRelays = await RelayController.getInstance().publishDM(
|
||||
signedEvent,
|
||||
receiver
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Handle cases where publishing to the relays failed
|
||||
if (publishedOnRelays.length === 0) {
|
||||
|
@ -135,3 +135,24 @@ 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
1
src/vite-env.d.ts
vendored
@ -6,6 +6,7 @@ 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...
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user