Compare commits

..

No commits in common. "staging" and "170-editor-update" have entirely different histories.

154 changed files with 60666 additions and 9389 deletions

View File

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

View File

@ -4,15 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Icons -->
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="DEG Mods" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Links and Stylesheets -->
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />

178
package-lock.json generated
View File

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

View File

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

View File

@ -1,6 +1,5 @@
{
"names": {
"_": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
"degmods": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
"degmodsreposter": "7382a4cc21742ac3e3581f1c653a41f912e985e6a941439377803b866042e53f",
"degmodsreport": "ca2734bb5e59427dd5d66f59dde3b4a045110b7a12eb99a4a862bf012b7850d9",
@ -9,7 +8,6 @@
"Merlin": "76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698",
"makano": "fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10",
"reya": "126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede",
"gradash": "e0c92699e3f3baacb775cb033b0541af381c7ed9fde255ac556048c1bcaf0432"
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1672)"><rect width="1000" height="1000" fill="#232323"></rect><g transform="matrix(1.0449442115895924,0,0,1.0449442115895924,170.4245956646426,50)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="630.8" height="861.29"><svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630.8 861.29">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
<clipPath id="SvgjsClipPath1672"><rect width="1000" height="1000" x="0" y="0" rx="500" ry="500"></rect></clipPath></defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M237.92,569.84c0-38.78-31.44-70.22-70.22-70.22s-70.22,31.44-70.22,70.22,31.44,70.22,70.22,70.22,70.22-31.44,70.22-70.22Zm-112.92-.38c0-23.58,19.12-42.7,42.7-42.7s42.7,19.11,42.7,42.7-19.12,42.7-42.7,42.7-42.7-19.12-42.7-42.7Z"></path>
<path class="cls-1" d="M462.6,499.61c-38.78,0-70.22,31.44-70.22,70.22s31.44,70.22,70.22,70.22,70.22-31.44,70.22-70.22-31.44-70.22-70.22-70.22Zm0,112.54c-23.58,0-42.7-19.12-42.7-42.7s19.12-42.7,42.7-42.7,42.7,19.11,42.7,42.7-19.12,42.7-42.7,42.7Z"></path>
<path class="cls-1" d="M615.78,435.15s0,0,0,0c0-.02-.01-.03-.02-.05-5.37-19.37-12.13-38.1-20.19-54.96-33.72-79.69-90.11-172.53-147.06-237.09,8.41,329.27-136.89,120.61-99.7-143.05-121.74,100.78-187.28,276.34-187.28,338.12-19.77-25.06-35.72-65.99-22.23-113.79C40.32,305.6-13.81,467.05,3.05,583.09c18.23,156.62,151.26,278.21,312.77,278.21,173.96,0,314.94-141.76,314.94-315.71,.58-33.27-4.61-73.04-14.97-110.43Zm-197.98-36.97c26.61-7.51,51.84,9.36,54.21,17.76,2.37,8.4-16.79,23.02-43.4,30.54-26.61,7.51-49.53,8.82-52.96-3.32s15.53-37.46,42.14-44.97Zm-262.34,17.76c2.37-8.4,27.6-25.28,54.21-17.76,26.61,7.51,45.57,32.83,42.14,44.97s-26.35,10.84-52.96,3.32c-26.61-7.51-45.77-22.14-43.4-30.54Zm-89,153.89c0-1.15,.05-2.28,.09-3.42-6.05-15.16-10.73-38.24-12.04-73.13,11.93,9.76,21.14,16.61,28.32,21.39,18.06-27.73,49.32-46.08,84.88-46.08,55.92,0,101.24,45.33,101.24,101.24s-45.33,101.24-101.24,101.24-101.24-45.33-101.24-101.24Zm267.7,257.85c-71.14,7.8-143.59-29.61-171.19-91.79,54.5-2.37,122.48-8.33,159.76-12.42,11.33-1.24,23.24-2.82,36.04-4.81l35.62,30.45,23.98-41.42c14.25-2.97,29.55-6.36,46.14-10.23-8.02,80.05-59.21,122.42-130.35,130.22Zm229.69-257.85c0,55.92-45.33,101.24-101.24,101.24s-101.24-45.33-101.24-101.24,45.33-101.24,101.24-101.24c35.57,0,66.84,18.36,84.9,46.11,7.18-4.78,16.4-11.63,28.36-21.41-1.31,34.99-6.02,58.1-12.09,73.26,.03,1.09,.08,2.18,.08,3.28Z"></path>
</g>
</g>
</svg></svg></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,21 +0,0 @@
{
"name": "DEG Mods",
"short_name": "DEG Mods",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#232323",
"background_color": "#232323",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

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

View File

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

View File

@ -1,3 +1,2 @@
Game Name,16 by 9 image,Boxart image
Fire Emblem Engage,,https://image.nostr.build/f9f883f88c7d1abc38b98b0aa2394684e52e10171b621011f348034ab9973476.jpg
Super Smash Bros. Ultimate,,https://image.nostr.build/82a62ca20eee9306b740ba738b37dc2af2c26705b5430491f170114a5edc6cc4.jpg
Fire Emblem Engage,,https://image.nostr.build/f9f883f88c7d1abc38b98b0aa2394684e52e10171b621011f348034ab9973476.jpg
1 Game Name 16 by 9 image Boxart image
2 Fire Emblem Engage https://image.nostr.build/f9f883f88c7d1abc38b98b0aa2394684e52e10171b621011f348034ab9973476.jpg
Super Smash Bros. Ultimate https://image.nostr.build/82a62ca20eee9306b740ba738b37dc2af2c26705b5430491f170114a5edc6cc4.jpg

View File

@ -6,15 +6,4 @@ Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
The Elder Scrolls: Skyblivion,,https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
Stellar Blade,,https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
Bayonetta 2,,https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
Grand Theft Auto: Vice City,,https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
Alan Wake 2,,https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
Guitar Hero World Tour,,https://image.nostr.build/7b0567e703e489479646be65ecf8a998edc092ff0e01f8c85d1726b327bc26f8.jpg
Gravity Rush,,https://image.nostr.build/cc918c24940606e8b293cb25533d9bc7bff5b88ee393869ad2faddb8ad52e564.jpg
Gravity Rush 2,,https://image.nostr.build/d6240f12a5d4e38295afc833ebf9624b16cd1753d1a2e679b98c574997677748.jpg
Gravity Rush: Remastered,,https://image.nostr.build/8bdfaab2ffe928a2dd2901fc1f7f006506a8e91272222a8e12023a5485c570b8.jpg
Minecraft: Bedrock Edition,,https://image.nostr.build/0bae20dbba8a7410c2972b12edc5bba30a32715794738f6c39b378eda09e25be.jpg
Wuthering Waves,,https://image.nostr.build/393fef1c44feba76cf797f1cdc536f75d952876bce553d8c2e4118a8adc3b733.jpg
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
1 Game Name 16 by 9 image Boxart image
6 Genshin Impact https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
7 Zenless Zone Zero https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
8 Ananta https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
9 Bloodborne https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
The Elder Scrolls: Skyblivion https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
Stellar Blade https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
Bayonetta 2 https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
Grand Theft Auto: Vice City https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
Alan Wake 2 https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
Guitar Hero World Tour https://image.nostr.build/7b0567e703e489479646be65ecf8a998edc092ff0e01f8c85d1726b327bc26f8.jpg
Gravity Rush https://image.nostr.build/cc918c24940606e8b293cb25533d9bc7bff5b88ee393869ad2faddb8ad52e564.jpg
Gravity Rush 2 https://image.nostr.build/d6240f12a5d4e38295afc833ebf9624b16cd1753d1a2e679b98c574997677748.jpg
Gravity Rush: Remastered https://image.nostr.build/8bdfaab2ffe928a2dd2901fc1f7f006506a8e91272222a8e12023a5485c570b8.jpg
Minecraft: Bedrock Edition https://image.nostr.build/0bae20dbba8a7410c2972b12edc5bba30a32715794738f6c39b378eda09e25be.jpg
Wuthering Waves https://image.nostr.build/393fef1c44feba76cf797f1cdc536f75d952876bce553d8c2e4118a8adc3b733.jpg

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -1,16 +1,12 @@
import { createPortal } from 'react-dom'
import { PropsWithChildren } from 'react'
import { AlertPopupProps } from 'types'
export const AlertPopup = ({
header,
label,
handleConfirm,
handleClose,
yesButtonLabel = 'Yes',
noButtonLabel = 'No',
children
}: PropsWithChildren<AlertPopupProps>) => {
handleClose
}: AlertPopupProps) => {
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
@ -42,7 +38,6 @@ export const AlertPopup = ({
>
{label}
</label>
{children}
</div>
<div
style={{
@ -56,14 +51,14 @@ export const AlertPopup = ({
type='button'
onPointerDown={() => handleConfirm(true)}
>
{yesButtonLabel}
Yes
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => handleConfirm(false)}
>
{noButtonLabel}
No
</button>
</div>
</div>

View File

@ -209,7 +209,7 @@ export const CategoryAutocomplete = ({
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Categories</label>
<p className='labelDescriptionMain'>You can select multiple categories, or even add your own custom category (ex: custom &gt; custom sub)</p>
<p className='labelDescriptionMain'>You can select multiple categories</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<input

View File

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

View File

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

View File

@ -1,114 +0,0 @@
import React from 'react'
import { PropsWithChildren } from 'react'
import { Filter } from '.'
import { FilterOptions, RepostFilter, SortBy } from 'types'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { useLocalStorage } from 'hooks'
import { NsfwFilterOptions } from './NsfwFilterOptions'
interface FeedFilterProps {
tab: number
}
export const FeedFilter = React.memo(
({ tab, children }: PropsWithChildren<FeedFilterProps>) => {
const filterKey = `filter-feed-${tab}`
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
{/* show only if not posts tabs */}
{tab !== 2 && (
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
)}
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions
filterKey={filterKey}
{...(tab === 2
? {
skipOnlyNsfw: true
}
: {})}
/>
</Dropdown>
{/* source filter options */}
{/* show only if not posts tabs */}
{tab !== 2 && (
<Dropdown
label={
filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'
}
>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</Option>
<Option
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</Option>
</Dropdown>
)}
{/* Repost filter */}
{tab === 2 && (
<Dropdown label={filterOptions.repost}>
{Object.values(RepostFilter).map((item, index) =>
item === RepostFilter.Only_Repost ? null : (
<Option
key={`repost-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
repost: item
}))
}
>
{item}
</Option>
)
)}
</Dropdown>
)}
{children}
</Filter>
)
}
)

View File

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

View File

@ -7,13 +7,9 @@ import { DEFAULT_FILTER_OPTIONS } from 'utils'
interface NsfwFilterOptionsProps {
filterKey: string
skipOnlyNsfw?: boolean
}
export const NsfwFilterOptions = ({
filterKey,
skipOnlyNsfw
}: NsfwFilterOptionsProps) => {
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
@ -34,34 +30,29 @@ export const NsfwFilterOptions = ({
return (
<>
{Object.values(NSFWFilter).map((item, index) => {
// Posts feed filter exception
if (item === NSFWFilter.Only_NSFW && skipOnlyNsfw) return null
return (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() => {
// Trigger NSFW popup
if (
(item === NSFWFilter.Only_NSFW ||
item === NSFWFilter.Show_NSFW) &&
!confirmNsfw
) {
setSelectedNsfwOption(item)
setShowNsfwPopup(true)
} else {
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
}}
>
{item}
</Option>
)
})}
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() => {
// Trigger NSFW popup
if (
(item === NSFWFilter.Only_NSFW ||
item === NSFWFilter.Show_NSFW) &&
!confirmNsfw
) {
setSelectedNsfwOption(item)
setShowNsfwPopup(true)
} else {
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
}}
>
{item}
</Option>
))}
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,357 +0,0 @@
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { CommentContent } from 'components/comment/CommentContent'
import { Reactions } from 'components/comment/Reactions'
import { Zap } from 'components/comment/Zap'
import { Dots } from 'components/Spinner'
import { formatDate } from 'date-fns'
import {
useAppSelector,
useDidMount,
useLocalStorage,
useNDKContext
} from 'hooks'
import { useComments } from 'hooks/useComments'
import { nip19 } from 'nostr-tools'
import { useState } from 'react'
import { Link, useNavigation, useSubmit } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import { FeedPostsFilter, NSFWFilter, UserProfile } from 'types'
import { DEFAULT_FILTER_OPTIONS, hexToNpub, log, LogType } from 'utils'
import { NoteRepostPopup } from './NoteRepostPopup'
import { NoteQuoteRepostPopup } from './NoteQuoteRepostPopup'
import { NsfwCommentWrapper } from 'components/NsfwCommentWrapper'
interface NoteProps {
ndkEvent: NDKEvent
}
export const Note = ({ ndkEvent }: NoteProps) => {
const { ndk } = useNDKContext()
const submit = useSubmit()
const navigation = useNavigation()
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
const [eventProfile, setEventProfile] = useState<UserProfile>()
const isRepost = ndkEvent.kind === NDKKind.Repost
const filterKey = 'filter-feed-2'
const [filterOptions] = useLocalStorage<FeedPostsFilter>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
const [repostEvent, setRepostEvent] = useState<NDKEvent | undefined>()
const [repostProfile, setRepostProfile] = useState<UserProfile | undefined>()
const noteEvent = repostEvent ?? ndkEvent
const isNsfw = noteEvent
.getMatchingTags('L')
.some((t) => t[1] === 'content-warning')
const noteProfile = repostProfile ?? eventProfile
const { commentEvents } = useComments(
noteEvent.pubkey,
undefined,
noteEvent.id
)
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
const [hasQuoted, setHasQuoted] = useState(false)
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
const [hasReposted, setHasReposted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [showRepostPopup, setShowRepostPopup] = useState(false)
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
useDidMount(() => {
setIsLoading(true)
ndkEvent.author.fetchProfile().then((res) => setEventProfile(res))
if (isRepost) {
try {
const parsedEvent = JSON.parse(ndkEvent.content)
const ndkRepostEvent = new NDKEvent(ndk, parsedEvent)
setRepostEvent(ndkRepostEvent)
ndkRepostEvent.author
.fetchProfile()
.then((res) => setRepostProfile(res))
} catch (error) {
if (error instanceof SyntaxError) {
log(
true,
LogType.Error,
'Event content malformed',
error,
ndkEvent.content
)
} else {
log(true, LogType.Error, error)
}
}
}
const repostFilter: NDKFilter = {
kinds: [NDKKind.Repost],
'#e': [ndkEvent.id]
}
const quoteFilter: NDKFilter = {
kinds: [NDKKind.Text],
'#q': [ndkEvent.id]
}
ndk
.fetchEvents([repostFilter, quoteFilter], {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
if (ndkEventSet.size) {
const quoteRepostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Text
)
userPubkey &&
setHasQuoted(
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
)
setQuoteRepostEvents(quoteRepostEvents)
const repostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Repost
)
userPubkey &&
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
setRepostEvents(repostEvents)
}
})
.finally(() => {
setIsLoading(false)
})
})
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: noteEvent.pubkey
})
)
const reposterRoute = repostEvent
? getProfilePageRoute(
nip19.nprofileEncode({
pubkey: ndkEvent.pubkey
})
)
: undefined
const baseUrl = appRoutes.feed + '/'
// Show who reposted the note
const reposterVisual =
repostEvent && reposterRoute ? (
<>
<div className='IBMSMSMBSSCL_CommentRepost'>
<div className='IBMSMSMBSSCL_CommentRepostVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
</div>
<p className='IBMSMSMBSSCL_CommentRepostText'>
<Link to={reposterRoute}>
{eventProfile?.displayName || eventProfile?.name || ''}{' '}
</Link>
Reposted...
</p>
</div>
</>
) : null
const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false)
// Cancel if not confirmed
if (!confirm) return
const repostNdkEvent = await noteEvent.repost(false)
const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
data: rawEvent
}),
{
method: 'post',
encType: 'application/json'
}
)
}
// Is this user's repost?
const isUsersRepost =
isRepost && ndkEvent.author.pubkey === userState.user?.pubkey
return (
<div className='IBMSMSMBSSCL_CommentWrapper'>
<div className='IBMSMSMBSSCL_Comment'>
{reposterVisual}
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
noteProfile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{noteProfile?.displayName || noteProfile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(noteEvent.pubkey)}
</Link>
</div>
{noteEvent.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<Link
to={baseUrl + noteEvent.encode()}
className='IBMSMSMBSSCL_CADTime'
>
{formatDate(noteEvent.created_at * 1000, 'hh:mm aa')}{' '}
</Link>
<Link
to={baseUrl + noteEvent.encode()}
className='IBMSMSMBSSCL_CADDate'
>
{formatDate(noteEvent.created_at * 1000, 'dd/MM/yyyy')}
</Link>
</div>
)}
</div>
</div>
<NsfwCommentWrapper
id={noteEvent.id}
isNsfw={isNsfw}
hideNsfwActive={NSFWFilter.Hide_NSFW === filterOptions.nsfw}
>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={noteEvent.content} isNsfw={isNsfw} />
</div>
</NsfwCommentWrapper>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...noteEvent.rawEvent()} />
{/* Quote Repost, Kind 1 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading ? undefined : () => setShowQuoteRepostPopup(true)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : quoteRepostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showQuoteRepostPopup && (
<NoteQuoteRepostPopup
ndkEvent={noteEvent}
handleClose={() => setShowQuoteRepostPopup(false)}
/>
)}
{/* Repost, Kind 6 */}
{!isUsersRepost && (
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading || hasReposted
? undefined
: () => setShowRepostPopup(true)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : repostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
)}
{showRepostPopup && (
<NoteRepostPopup
ndkEvent={noteEvent}
handleConfirm={handleRepost}
handleClose={() => setShowRepostPopup(false)}
/>
)}
{typeof noteProfile?.lud16 !== 'undefined' &&
noteProfile.lud16 !== '' && <Zap {...noteEvent.rawEvent()} />}
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
to={baseUrl + noteEvent.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</Link>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
to={baseUrl + noteEvent.encode()}
>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</Link>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,23 +0,0 @@
import { NoteRender } from './NoteRender'
interface NotePreviewProps {
content: string
}
export const NotePreview = ({ content }: NotePreviewProps) => {
return (
<div className='feedPostsPostPreview'>
<div className='feedPostsPostPreviewNote'>
<p>
Previewing post
<br />
</p>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<div className='IBMSMSMBSSCL_CBText'>
<NoteRender content={content} />
</div>
</div>
</div>
)
}

View File

@ -1,61 +0,0 @@
import { createPortal } from 'react-dom'
import { PropsWithChildren } from 'react'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { NoteSubmit } from './NoteSubmit'
interface NoteQuoteRepostPopup {
ndkEvent: NDKEvent
handleClose: () => void
}
export const NoteQuoteRepostPopup = ({
ndkEvent,
handleClose
}: PropsWithChildren<NoteQuoteRepostPopup>) => {
const content = `nostr:${ndkEvent.encode()}`
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Quote Repost</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' }}
>
Quote repost this?
</label>
<NoteSubmit
initialContent={`\n\n${content}`}
handleClose={handleClose}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@ -1,212 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk'
import { ProfileLink } from 'components/ProfileSection'
import { nip19 } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react'
import { Fragment } from 'react/jsx-runtime'
import { BlogPreview } from './internal/BlogPreview'
import { ModPreview } from './internal/ModPreview'
import { NoteWrapper } from './internal/NoteWrapper'
import {
getIdFromYoutubeLink,
isValidAudioUrl,
isValidImageUrl,
isValidUrl,
isValidVideoUrl,
isYoutubeLink
} from 'utils'
import FsLightbox from 'fslightbox-react'
import { createPortal } from 'react-dom'
interface NoteRenderProps {
content: string
}
const link =
/(?:https?:\/\/|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]+(?::\d+)?)(?:[/?#][\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]*)?/u
const nostrMention =
/(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/i
const nostrEntity =
/(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/i
const nostrNip05Mention = /(?:nostr:|@)[^\s]{1,64}@[^\s]+\.[^\s]{2,}/i
const nip05Entity = /(?:nostr:|@)([^\s]{1,64}@[^\s]+\.[^\s]{2,})/i
export const NoteRender = ({ content }: NoteRenderProps) => {
const [lightBoxController, setLightBoxController] = useState({
toggler: false,
slide: 1
})
const slides = useMemo(() => {
const sources: { url: string; type: 'image' | 'video' | 'youtube' }[] = []
const parts = content.matchAll(new RegExp(`${link.source}`, 'gui'))
;[...parts]
.filter((p) => typeof p !== 'undefined')
.forEach((part) => {
const [href] = part
if (href && isValidUrl(href)) {
if (isValidImageUrl(href)) {
sources.push({ url: href, type: 'image' })
} else if (isValidVideoUrl(href)) {
sources.push({ url: href, type: 'video' })
} else if (isYoutubeLink(href)) {
const id = getIdFromYoutubeLink(href)
if (id) {
sources.push({
url: `https://www.youtube.com/embed/${id}`,
type: 'youtube'
})
}
}
}
})
return sources
}, [content])
const openLightBoxOnSlide = useCallback(
(url: string) => {
slides.length &&
setLightBoxController((prev) => ({
toggler: !prev.toggler,
slide: slides.findIndex((s) => s.url === url) + 1
}))
},
[slides]
)
const _content = useMemo(() => {
if (!content) return
const parts = content.split(
new RegExp(
`(${link.source})|(${nostrMention.source})|(${nostrNip05Mention.source})`,
'gui'
)
)
const _parts = parts
.filter((p) => typeof p !== 'undefined')
.map((part, index) => {
const key = `${index}-${part}`
if (link.test(part)) {
const [href] = part.match(link) || []
if (href && isValidUrl(href)) {
if (isValidImageUrl(href)) {
// Image
return (
<img
key={key}
className='imgFeedRender'
src={href}
alt=''
onClick={() => openLightBoxOnSlide(href)}
/>
)
} else if (isValidVideoUrl(href)) {
// Video
return (
<video
key={key}
className='videoFeedRender'
src={href}
controls
/>
)
} else if (isValidAudioUrl(href)) {
return <audio key={key} src={href} controls />
} else if (isYoutubeLink(href)) {
// Youtube
const id = getIdFromYoutubeLink(href)
if (id) {
return (
<iframe
key={key}
className='videoFeedRender'
title='Video embed'
width='560'
height='315'
src={`https://www.youtube.com/embed/${id}`}
frameBorder='0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
></iframe>
)
}
}
}
// Link
return (
<a key={key} target='_blank' href={href}>
{href}
</a>
)
} else if (nostrMention.test(part)) {
const [encoded] = part.match(nostrEntity) || []
if (!encoded) return part
try {
const decoded = nip19.decode(encoded)
switch (decoded.type) {
case 'nprofile':
return <ProfileLink key={key} pubkey={decoded.data.pubkey} />
case 'npub':
return <ProfileLink key={key} pubkey={decoded.data} />
case 'note':
return <NoteWrapper key={key} noteEntity={encoded} />
case 'nevent':
return <NoteWrapper key={key} noteEntity={encoded} />
case 'naddr':
return (
<Fragment key={key}>
{handleNaddr(decoded.data, part)}
</Fragment>
)
default:
return part
}
} catch (error) {
return part
}
} else if (nostrNip05Mention.test(part)) {
const matches = nip05Entity.exec(part) || []
const nip05 = matches?.[1] || part
return <ProfileLink key={key} nip05={nip05} fallback={nip05} />
} else {
return <Fragment key={key}>{part}</Fragment>
}
})
return _parts
}, [content, openLightBoxOnSlide])
return (
<>
{_content}
{slides.length > 0 &&
createPortal(
<FsLightbox
toggler={lightBoxController.toggler}
sources={slides.map((s) => s.url)}
types={slides.map((s) => s.type)}
slide={lightBoxController.slide}
/>,
document.body
)}
</>
)
}
function handleNaddr(data: nip19.AddressPointer, original: string) {
const { kind } = data
if (kind === NDKKind.Article) {
return <BlogPreview {...data} original={original} />
} else if (kind === NDKKind.Classified) {
return <ModPreview {...data} original={original} />
} else {
return <>{original}</>
}
}

View File

@ -1,125 +0,0 @@
import { AlertPopup } from 'components/AlertPopup'
import { useAppSelector, useDidMount } from 'hooks'
import { useState } from 'react'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Link } from 'react-router-dom'
import { CommentContent } from 'components/comment/CommentContent'
import { getProfilePageRoute } from 'routes'
import { nip19 } from 'nostr-tools'
import { UserProfile } from 'types'
import { hexToNpub, log, LogType } from 'utils'
import { formatDate } from 'date-fns'
interface NoteRepostProps {
ndkEvent: NDKEvent
handleConfirm: (confirm: boolean) => void
handleClose: () => void
}
export const NoteRepostPopup = ({
ndkEvent,
handleConfirm,
handleClose
}: NoteRepostProps) => {
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
const [content, setContent] = useState<string>('')
const [profile, setProfile] = useState<UserProfile>()
useDidMount(async () => {
const repost = await ndkEvent.repost(false)
ndkEvent.author.fetchProfile().then((res) => setProfile(res))
try {
setContent(JSON.parse(repost.content).content)
} catch (error) {
if (error instanceof SyntaxError) {
log(true, LogType.Error, 'Repost event content malformed', error)
} else {
log(true, LogType.Error, error)
}
}
})
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: ndkEvent.pubkey
})
)
const reposterRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userPubkey!
})
)
return (
<AlertPopup
handleConfirm={handleConfirm}
handleClose={handleClose}
header={'Repost'}
label={`Repost this?`}
yesButtonLabel='Repost Now'
noButtonLabel='Never mind'
>
<div className='IBMSMSMBSSCL_CommentQP'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentRepost'>
<div className='IBMSMSMBSSCL_CommentRepostVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
</div>
<p className='IBMSMSMBSSCL_CommentRepostText'>
<Link to={reposterRoute}>
{userState.user?.displayName || userState.user?.name || ''}{' '}
</Link>
Reposted...
</p>
</div>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(ndkEvent.pubkey)}
</Link>
</div>
{ndkEvent.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(ndkEvent.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(ndkEvent.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={content} />
</div>
</div>
</div>
</AlertPopup>
)
}

View File

@ -1,220 +0,0 @@
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
import { FALLBACK_PROFILE_IMAGE } from '../../constants'
import { useAppSelector, useLocalCache } from 'hooks'
import { useProfile } from 'hooks/useProfile'
import {
Navigate,
useActionData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { appRoutes } from 'routes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils'
import { NotePreview } from './NotePreview'
import { NoteSubmitActionResult, NoteSubmitForm } from 'types'
import { InputError } from 'components/Inputs/Error'
import { AlertPopup } from 'components/AlertPopup'
interface NoteSubmitProps {
initialContent?: string | undefined
handleClose?: () => void | undefined
}
export const NoteSubmit = ({
initialContent,
handleClose
}: NoteSubmitProps) => {
const navigation = useNavigation()
const userState = useAppSelector((state) => state.user)
const profile = useProfile(userState.user?.pubkey as string | undefined, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
const [cache, setCache] = useLocalCache<NoteSubmitForm>(NOTE_DRAFT_CACHE_KEY)
const [content, setContent] = useState(initialContent ?? cache?.content ?? '')
const [nsfw, setNsfw] = useState(cache?.nsfw ?? false)
const [showPreview, setShowPreview] = useState(!!initialContent)
const image = useMemo(
() => profile?.image || FALLBACK_PROFILE_IMAGE,
[profile?.image]
)
const ref = useRef<HTMLTextAreaElement>(null)
const submit = useSubmit()
const actionData = useActionData() as NoteSubmitActionResult
const formErrors = useMemo(
() =>
actionData?.type === 'validation' ? actionData.formErrors : undefined,
[actionData]
)
useEffect(() => {
if (ref.current && (!!initialContent || !!cache?.content)) {
adjustTextareaHeight(ref.current)
ref.current.focus()
}
}, [cache?.content, initialContent])
useEffect(() => {
setCache({
content,
nsfw
})
}, [content, nsfw, setCache])
useEffect(() => {
if (ref.current) adjustTextareaHeight(ref.current)
}, [content])
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
useEffect(() => {
const isTimeout = actionData?.type === 'timeout'
setShowTryAgainPopup(isTimeout)
if (isTimeout && actionData.action.intent === 'submit') {
setContent(actionData.action.data.content)
setNsfw(actionData.action.data.nsfw)
}
}, [actionData])
const handleFormSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault()
const formSubmit = {
intent: 'submit',
data: {
content,
nsfw
}
}
// Reset form
setContent('')
setNsfw(false)
setShowPreview(false)
submit(JSON.stringify(formSubmit), {
method: 'post',
encType: 'application/json',
action: appRoutes.feed
})
typeof handleClose === 'function' && handleClose()
},
[content, handleClose, nsfw, submit]
)
const handleTryAgainConfirm = useCallback(
(confirm: boolean) => {
setShowTryAgainPopup(false)
// Cancel if not confirmed
if (!confirm) return
handleFormSubmit()
},
[handleFormSubmit]
)
const handleContentChange = (
event: React.ChangeEvent<HTMLTextAreaElement>
) => {
setContent(event.currentTarget.value)
}
const handlePreviewToggle = () => {
setShowPreview((prev) => !prev)
}
if (!userState.user?.pubkey) return <Navigate to={appRoutes.home} />
return (
<>
<form className='feedPostsPost' onSubmit={handleFormSubmit}>
<div className='feedPostsPostInside'>
<div className='feedPostsPostInsideInputWrapper'>
<div className='feedPostsPostInsidePP'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<div
className='IBMSMSMBSSCL_CommentTopPP'
style={{
background: `url(${image}) center/cover no-repeat`,
width: '50px',
height: '50px',
borderRadius: '8px'
}}
></div>
</div>
</div>
<textarea
id='postSocialTextarea'
name='content'
className='inputMain feedPostsPostInsideInput'
placeholder='Watcha thinking about?'
ref={ref}
value={content}
onChange={handleContentChange}
/>
</div>
<div className='feedPostsPostInsideAction'>
<div
className='inputLabelWrapperMain inputLabelWrapperMainAlt'
style={{ width: 'unset' }}
>
<input
type='checkbox'
className='CheckboxMain'
checked={nsfw}
id='nsfw'
name='nsfw'
onChange={() => setNsfw((nsfw) => !nsfw)}
/>
<label htmlFor='nsfw' className='form-label labelMain'>
NSFW
</label>
</div>
<div
style={{ display: 'flex', flexDirection: 'row', gridGap: '10px' }}
>
{typeof handleClose === 'function' && (
<button
className='btn btnMain'
type='button'
style={{ padding: '5px 20px', borderRadius: '8px' }}
onClick={handleClose}
>
Close
</button>
)}
<button
className='btn btnMain'
type='button'
style={{ padding: '5px 20px', borderRadius: '8px' }}
onClick={handlePreviewToggle}
>
Preview
</button>
<button
className='btn btnMain'
type='submit'
style={{ padding: '5px 20px', borderRadius: '8px' }}
disabled={navigation.state !== 'idle' || !content.length}
>
{navigation.state === 'submitting' ? 'Posting...' : 'Post'}
</button>
</div>
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{showPreview && <NotePreview content={content} />}
{showTryAgainPopup && (
<AlertPopup
handleConfirm={handleTryAgainConfirm}
handleClose={() => setShowTryAgainPopup(false)}
header={'Post'}
label={`Posting timed out. Do you want to try again?`}
/>
)}
</div>
</form>
</>
)
}

View File

@ -1,25 +0,0 @@
import { nip19 } from 'nostr-tools'
import { Link } from 'react-router-dom'
import { getBlogPageRoute } from 'routes'
import { truncate } from 'utils'
type BlogPreviewProps = nip19.AddressPointer & {
original: string
}
export const BlogPreview = ({
identifier,
kind,
pubkey,
original
}: BlogPreviewProps) => {
const route = getBlogPageRoute(
nip19.naddrEncode({
identifier,
pubkey,
kind
})
)
return <Link to={route}>{truncate(original)}</Link>
}

View File

@ -1,25 +0,0 @@
import { nip19 } from 'nostr-tools'
import { Link } from 'react-router-dom'
import { getModPageRoute } from 'routes'
import { truncate } from 'utils'
type ModPreviewProps = nip19.AddressPointer & {
original: string
}
export const ModPreview = ({
identifier,
kind,
pubkey,
original
}: ModPreviewProps) => {
const route = getModPageRoute(
nip19.naddrEncode({
identifier,
pubkey,
kind
})
)
return <Link to={route}>{truncate(original)}</Link>
}

View File

@ -1,85 +0,0 @@
import { useDidMount, useNDKContext } from 'hooks'
import { Link } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes'
import { filterFromId, NDKEvent } from '@nostr-dev-kit/ndk'
import { useState } from 'react'
import { UserProfile } from 'types'
import { hexToNpub } from 'utils'
import { formatDate } from 'date-fns'
import { CommentContent } from 'components/comment/CommentContent'
import { Dots } from 'components/Spinner'
interface NoteWrapperProps {
noteEntity: string
}
export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => {
const { fetchEvent } = useNDKContext()
const [note, setNote] = useState<NDKEvent>()
const [profile, setProfile] = useState<UserProfile>()
useDidMount(() => {
const filter = filterFromId(noteEntity)
fetchEvent(filter).then((ndkEvent) => {
if (ndkEvent) {
setNote(ndkEvent)
ndkEvent.author.fetchProfile().then((res) => setProfile(res))
}
})
})
const profileRoute = note?.author.nprofile
? getProfilePageRoute(note?.author.nprofile)
: appRoutes.home
if (!note) return <Dots />
const baseUrl = appRoutes.feed + '/'
return (
<div className='IBMSMSMBSSCL_CommentQP'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(note.pubkey)}
</Link>
</div>
{note.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<Link
to={baseUrl + noteEntity}
className='IBMSMSMBSSCL_CADTime'
>
{formatDate(note.created_at * 1000, 'hh:mm aa')}{' '}
</Link>
<Link
to={baseUrl + noteEntity}
className='IBMSMSMBSSCL_CADDate'
>
{formatDate(note.created_at * 1000, 'dd/MM/yyyy')}
</Link>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={note.content} />
</div>
</div>
</div>
)
}

View File

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

View File

@ -1,60 +0,0 @@
import { useLocalStorage, useSessionStorage } from 'hooks'
import { PropsWithChildren, useState } from 'react'
import { NsfwAlertPopup } from './NsfwAlertPopup'
interface NsfwCommentWrapperProps {
id: string
isNsfw: boolean
hideNsfwActive: boolean
}
export const NsfwCommentWrapper = ({
id,
isNsfw,
hideNsfwActive,
children
}: PropsWithChildren<NsfwCommentWrapperProps>) => {
// Have we approved show nsfw comment button
const [viewNsfwComment, setViewNsfwComment] = useSessionStorage<boolean>(
id,
false
)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const handleConfirm = (confirm: boolean) => {
if (confirm) {
setShowNsfwPopup(confirm)
setViewNsfwComment(true)
}
}
const handleShowNSFW = () => {
if (confirmNsfw) {
setViewNsfwComment(true)
} else {
setShowNsfwPopup(true)
}
}
// Skip NSFW wrapper
// if comment is not marked as NSFW
// if user clicked View NSFW button
// if hide filter is not active
if (!isNsfw || viewNsfwComment || !hideNsfwActive) return children
return (
<>
<div className='IBMSMSMBSSCL_CommentNSFW'>
<p>This post is hidden as it&#39;s marked as NSFW</p>
<button className='btnMain' type='button' onClick={handleShowNSFW}>
View this NSFW post
</button>
</div>
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</>
)
}

View File

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

View File

@ -1,7 +1,7 @@
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { QRCodeSVG } from 'qrcode.react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'react-toastify'
import {
@ -10,7 +10,7 @@ import {
useDidMount,
useNDKContext
} from '../hooks'
import { appRoutes, getFeedNotePageRoute, getProfilePageRoute } from '../routes'
import { appRoutes, getProfilePageRoute } from '../routes'
import '../styles/author.css'
import '../styles/innerPage.css'
import '../styles/socialPosts.css'
@ -18,128 +18,71 @@ import { UserRelaysType } from '../types'
import {
copyTextToClipboard,
hexToNpub,
isValidImageUrl,
isValidUrl,
log,
LogType,
now,
npubToHex,
truncate
npubToHex
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap'
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { useProfile } from 'hooks/useProfile'
import { createPortal } from 'react-dom'
type Props = {
pubkey: string
}
export const ProfileSection = ({ pubkey }: Props) => {
const { ndk } = useNDKContext()
const profile = useProfile(pubkey, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
})
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
const filter: NDKFilter = {
authors: [pubkey],
kinds: [NDKKind.Text],
limit: 10
}
ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
const posts: Post[] = ndkEvents
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.map((ndkEvent) => ({
content: ndkEvent.content,
link: getFeedNotePageRoute(ndkEvent.encode()),
name: displayName
}))
posts.splice(5)
for (const post of posts) {
const imageUrls = post.content.match(
new RegExp(
/(?:https?:\/\/|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]+(?::\d+)?)(?:[/?#][\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]*)?/gu
)
)
if (imageUrls) {
for (const url of imageUrls) {
if (isValidUrl(url) && isValidImageUrl(url)) {
post.imageUrl = url
break
}
}
}
}
setPosts(posts)
})
}, [displayName, ndk, pubkey])
return (
<div className='IBMSMSplitMainSmallSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile pubkey={pubkey} />
</div>
{posts.length > 0 && (
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
{posts.map((post, index) => (
<a
key={'post' + index}
className='IBMSMSMSSS_ShortPostsPostLink'
href={post.link}
>
<div className='IBMSMSMSSS_ShortPostsPost'>
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
{post.name}
</p>
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
style={{ width: '100%', height: '100%' }}
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
</svg>
</div>
</div>
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
<p>{post.content}</p>
{post.imageUrl && (
<div
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
style={{
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
}}
></div>
)}
<div className='IBMSMSplitMainSmallSideSec'>
<div className='IBMSMSMSSS_ShortPosts'>
{posts.map((post, index) => (
<a
key={'post' + index}
className='IBMSMSMSSS_ShortPostsPostLink'
href={post.link}
>
<div className='IBMSMSMSSS_ShortPostsPost'>
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
{post.name}
</p>
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
style={{ width: '100%', height: '100%' }}
>
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
</svg>
</div>
</div>
</a>
))}
</div>
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
<p>{post.content}</p>
{post.imageUrl && (
<div
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
style={{
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
}}
></div>
)}
</div>
</div>
</a>
))}
</div>
)}
</div>
</div>
</div>
)
@ -150,9 +93,7 @@ type ProfileProps = {
}
export const Profile = ({ pubkey }: ProfileProps) => {
const profile = useProfile(pubkey, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
})
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
@ -214,7 +155,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
<div className='IBMSMSMSSS_Author_TopWrapper'>
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
{typeof nip05 === 'string' && nip05 !== '' && (
{typeof nip05 === 'string' && (
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
)}
</div>
@ -250,7 +191,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
{typeof nprofile !== 'undefined' && (
<ProfileQRButtonWithPopUp nprofile={nprofile} />
)}
{typeof lud16 !== 'undefined' && lud16 !== '' && (
{typeof lud16 !== 'undefined' && (
<ZapButtonWithPopUp pubkey={pubkey} />
)}
</div>
@ -277,6 +218,25 @@ interface Post {
imageUrl?: string
}
const posts: Post[] = [
{
name: 'User name',
link: `feed-note.html`,
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
},
{
name: 'User name',
link: 'feed-note.html',
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`
},
{
name: 'User name',
link: `feed-note.html`,
content: `user text, this is a long string of temporary text that would be replaced with the user post from their short posts`,
imageUrl: placeholder
}
]
type QRButtonWithPopUpProps = {
nprofile: string
}
@ -288,6 +248,15 @@ export const ProfileQRButtonWithPopUp = ({
useBodyScrollDisable(isOpen)
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
const a = document.createElement('a')
a.href = href
a.target = '_blank' // Open in a new tab
a.rel = 'noopener noreferrer' // Recommended for security reasons
a.click()
}
return (
<>
<div
@ -307,65 +276,45 @@ export const ProfileQRButtonWithPopUp = ({
</div>
{isOpen && (
<ProfileQRPopup
nprofile={nprofile}
handleClose={() => setIsOpen(false)}
/>
)}
</>
)
}
interface ProfileQRPopupProps {
nprofile: string
handleClose: () => void
}
const ProfileQRPopup = ({ nprofile, handleClose }: ProfileQRPopupProps) => {
const onQrCodeClicked = async () => {
const href = `https://njump.me/${nprofile}`
const a = document.createElement('a')
a.href = href
a.target = '_blank' // Open in a new tab
a.rel = 'noopener noreferrer' // Recommended for security reasons
a.click()
}
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Nostr Address</h3>
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Nostr Address</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => setIsOpen(false)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='popUpMainCardBottom'>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={nprofile}
height={235}
width={235}
/>
</div>
</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='popUpMainCardBottom'>
<QRCodeSVG
className='popUpMainCardBottomQR'
onClick={onQrCodeClicked}
value={nprofile}
height={235}
width={235}
/>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
</>
)
}
@ -430,25 +379,11 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
}
})
// Hide follow if own profile
if (
userState.auth &&
userState.user?.pubkey &&
userState.user?.pubkey === pubkey
) {
return null
}
const getUserPubKey = async (): Promise<string | null> => {
if (userState.auth && userState.user?.pubkey) {
return userState.user.pubkey as string
} else {
try {
return (await window.nostr?.getPublicKey()) as string
} catch (error) {
log(true, LogType.Error, `Could not get pubkey`, error)
return null
}
return (await window.nostr?.getPublicKey()) as string
}
}
@ -612,58 +547,3 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
</>
)
}
type ProfileLinkProps = {
pubkey?: string
nip05?: string
fallback?: string
}
export const ProfileLink = ({ pubkey, nip05, fallback }: ProfileLinkProps) => {
const { ndk } = useNDKContext()
const [hexPubkey, setHexPubkey] = useState<string>()
const profile = useProfile(hexPubkey, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
useDidMount(async () => {
if (pubkey) {
setHexPubkey(npubToHex(pubkey)!)
} else if (nip05) {
ndk.getUserFromNip05(nip05).then((user) => {
if (user?.pubkey) {
setHexPubkey(npubToHex(user.pubkey)!)
}
})
}
})
const profileRoute = useMemo(() => {
let nprofile: string | undefined
try {
if (hexPubkey) {
nprofile = hexPubkey
? nip19.nprofileEncode({
pubkey: hexPubkey
})
: undefined
}
} catch (error) {
// Silently ignore
log(true, LogType.Error, 'Failed to encode profile.', error)
}
return nprofile ? getProfilePageRoute(nprofile) : undefined
}, [hexPubkey])
const displayName = useMemo(() => {
const npub = hexPubkey ? hexToNpub(hexPubkey) : ''
const displayName = profile?.displayName || profile?.name || truncate(npub)
return displayName
}, [hexPubkey, profile?.displayName, profile?.name])
if (!hexPubkey) return <Fragment>{fallback}</Fragment>
if (!profileRoute) return <Fragment>@{displayName}</Fragment>
return <Link to={profileRoute}>@{displayName}</Link>
}

View File

@ -3,11 +3,10 @@ import { forwardRef } from 'react'
interface SearchInputProps {
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
handleSearch: () => void
handleChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ handleKeyDown, handleSearch, handleChange }, ref) => (
({ handleKeyDown, handleSearch }, ref) => (
<div className='SearchMain'>
<div className='SearchMainInside'>
<div className='SearchMainInsideWrapper'>
@ -16,7 +15,6 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
className='SMIWInput'
ref={ref}
onKeyDown={handleKeyDown}
onChange={handleChange}
placeholder='Enter search term'
/>
<button

View File

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

View File

@ -1,286 +0,0 @@
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { formatDate } from 'date-fns'
import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
import { useState } from 'react'
import {
useParams,
useLocation,
Link,
useSubmit,
useNavigation
} from 'react-router-dom'
import {
getModPageRoute,
getBlogPageRoute,
getProfilePageRoute,
appRoutes
} from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { nip19 } from 'nostr-tools'
import { CommentContent } from './CommentContent'
import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup'
import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup'
import { useComments } from 'hooks/useComments'
interface CommentProps {
comment: CommentEvent
}
export const Comment = ({ comment }: CommentProps) => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const isNote = location.pathname.includes('/feed')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: isNote
? `${appRoutes.feed}/`
: undefined
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
const { commentEvents } = useComments(userPubkey, undefined, comment.event.id)
const [profile, setProfile] = useState<UserProfile>()
const submit = useSubmit()
const navigation = useNavigation()
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
const [hasReposted, setHasReposted] = useState(false)
const [hasQuoted, setHasQuoted] = useState(false)
const [showRepostPopup, setShowRepostPopup] = useState(false)
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
useDidMount(() => {
comment.event.author.fetchProfile().then((res) => setProfile(res))
const repostFilter: NDKFilter = {
kinds: [NDKKind.Repost],
'#e': [comment.event.id]
}
const quoteFilter: NDKFilter = {
kinds: [NDKKind.Text],
'#q': [comment.event.id]
}
ndk
.fetchEvents([repostFilter, quoteFilter], {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
if (ndkEventSet.size) {
const quoteRepostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Text
)
userPubkey &&
setHasQuoted(
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
)
setQuoteRepostEvents(quoteRepostEvents)
const repostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Repost
)
userPubkey &&
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
setRepostEvents(repostEvents)
}
})
})
const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false)
// Cancel if not confirmed
if (!confirm) return
const repostNdkEvent = await comment.event.repost(false)
const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
data: rawEvent
}),
{
method: 'post',
encType: 'application/json',
action: appRoutes.feed
}
)
}
const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: comment.event.pubkey
})
)
return (
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(comment.event.pubkey)}
</Link>
</div>
{comment.event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(comment.event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(comment.event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
{comment.status && (
<p className='IBMSMSMBSSCL_CBTextStatus'>
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
{comment.status}
</p>
)}
<CommentContent content={comment.event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...comment.event.rawEvent()} />
{comment.event.kind === NDKKind.Text && (
<>
{/* Quote Repost, Kind 1 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
navigation.state === 'idle'
? () => setShowQuoteRepostPopup(true)
: undefined
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{quoteRepostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showQuoteRepostPopup && (
<NoteQuoteRepostPopup
ndkEvent={comment.event}
handleClose={() => setShowQuoteRepostPopup(false)}
/>
)}
{/* Repost, Kind 6 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
navigation.state === 'idle'
? () => setShowRepostPopup(true)
: undefined
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{repostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showRepostPopup && (
<NoteRepostPopup
ndkEvent={comment.event}
handleConfirm={handleRepost}
handleClose={() => setShowRepostPopup(false)}
/>
)}
</>
)}
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
<Zap {...comment.event.rawEvent()} />
)}
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
to={baseUrl + comment.event.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>{commentEvents.length}</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</Link>
<Link
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
to={baseUrl + comment.event.encode()}
>
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
</Link>
</div>
</div>
</div>
)
}

View File

@ -1,40 +0,0 @@
import { NoteRender } from 'components/Notes/NoteRender'
import { useTextLimit } from 'hooks'
interface CommentContentProps {
content: string
isNsfw?: boolean
}
export const CommentContent = ({
content,
isNsfw = false
}: CommentContentProps) => {
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
return (
<>
{isExpanded && (
<div
className='IBMSMSMBSSCL_CBExpand IBMSMSMBSSCL_CBExpandAlt'
onClick={toggle}
>
<p>Hide full post</p>
</div>
)}
<div className='IBMSMSMBSSCL_CBText'>
<NoteRender content={text} />
</div>
{isTextOverflowing && !isExpanded && (
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
<p>View full post</p>
</div>
)}
{isNsfw && (
<div className='IBMSMSMBSSCL_CommentNSWFTag'>
<p>NSFW</p>
</div>
)}
</>
)
}

View File

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

View File

@ -1,551 +0,0 @@
import { formatDate } from 'date-fns'
import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext,
useReplies
} from 'hooks'
import { nip19 } from 'nostr-tools'
import {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import {
Link,
useLoaderData,
useLocation,
useNavigate,
useNavigation,
useParams,
useSubmit
} from 'react-router-dom'
import {
appRoutes,
getBlogPageRoute,
getModPageRoute,
getProfilePageRoute
} from 'routes'
import { CommentEvent, UserProfile } from 'types'
import { CommentsLoaderResult } from 'types/comments'
import { adjustTextareaHeight, handleCommentSubmit, hexToNpub } from 'utils'
import { Reactions } from './Reactions'
import { Zap } from './Zap'
import { Comment } from './Comment'
import { useComments } from 'hooks/useComments'
import { CommentContent } from './CommentContent'
import { Dots } from 'components/Spinner'
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup'
import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup'
import _ from 'lodash'
interface CommentsPopupProps {
title: string
}
export const CommentsPopup = ({ title }: CommentsPopupProps) => {
const { naddr } = useParams()
const location = useLocation()
const { ndk } = useNDKContext()
useBodyScrollDisable(true)
const isMod = location.pathname.includes('/mod/')
const isBlog = location.pathname.includes('/blog/')
const isNote = location.pathname.includes('/feed')
const baseUrl = naddr
? isMod
? getModPageRoute(naddr)
: isBlog
? getBlogPageRoute(naddr)
: undefined
: isNote
? `${appRoutes.feed}/`
: undefined
const { event } = useLoaderData() as CommentsLoaderResult
const eTags = event.getMatchingTags('e')
const lastETag = _.last(eTags)
const {
size,
parent: replyEvent,
isComplete,
root: rootEvent
} = useReplies(lastETag?.[1])
const isRoot =
event.kind === NDKKind.Text
? !event.getMatchingTags('e').length
: event.tagValue('a') === event.tagValue('A')
const [profile, setProfile] = useState<UserProfile>()
const { commentEvents, setCommentEvents } = useComments(
event.author.pubkey,
undefined,
event.id
)
useEffect(() => {
event.author.fetchProfile().then((res) => setProfile(res))
}, [event.author])
const profileRoute = useMemo(
() =>
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: event.pubkey
})
),
[event.pubkey]
)
const navigate = useNavigate()
const ref = useRef<HTMLTextAreaElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [replyText, setReplyText] = useState('')
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.currentTarget.value
setReplyText(value)
}, [])
useEffect(() => {
if (ref.current) adjustTextareaHeight(ref.current)
}, [replyText])
const [visible, setVisible] = useState<CommentEvent[]>([])
const discoveredCount = commentEvents.length - visible.length
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Initial loading to indicate comments fetching (stop after 5 seconds)
const t = window.setTimeout(() => setIsLoading(false), 5000)
return () => {
window.clearTimeout(t)
}
}, [])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
const handleComment = async () => {
setIsSubmitting(true)
const submitted = await handleSubmit(replyText)
if (submitted) setReplyText('')
setIsSubmitting(false)
}
const submit = useSubmit()
const navigation = useNavigation()
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
const [hasReposted, setHasReposted] = useState(false)
const [hasQuoted, setHasQuoted] = useState(false)
const [showRepostPopup, setShowRepostPopup] = useState(false)
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
useDidMount(() => {
const repostFilter: NDKFilter = {
kinds: [NDKKind.Repost],
'#e': [event.id]
}
const quoteFilter: NDKFilter = {
kinds: [NDKKind.Text],
'#q': [event.id]
}
ndk
.fetchEvents([repostFilter, quoteFilter], {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
if (ndkEventSet.size) {
const quoteRepostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Text
)
userPubkey &&
setHasQuoted(
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
)
setQuoteRepostEvents(quoteRepostEvents)
const repostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Repost
)
userPubkey &&
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
setRepostEvents(repostEvents)
}
})
.finally(() => {
setIsLoading(false)
})
})
const [showQuoteReposts, setShowQuoteReposts] = useState(false)
const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false)
// Cancel if not confirmed
if (!confirm) return
const repostNdkEvent = await event.repost(false)
const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
data: rawEvent
}),
{
method: 'post',
encType: 'application/json',
action: appRoutes.feed
}
)
}
return (
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{title}</h3>
</div>
<div
className='popUpMainCardTopClose'
onClick={() => navigate('..')}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='popUpMainCardBottom'>
<div className='pUMCB_PrimeComment'>
<div className='IBMSMSMBSSCL_Comment'>
<div className='IBMSMSMBSSCL_CommentTopOther'>
<div className='IBMSMSMBSSCL_CTO'>
{replyEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='IBMSMSMBSSCL_CTOLink'
to={baseUrl + replyEvent.encode()}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CTOLinkIcon'
>
<path d='M447.1 256C447.1 273.7 433.7 288 416 288H109.3l105.4 105.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L109.3 224H416C433.7 224 447.1 238.3 447.1 256z'></path>
</svg>
</Link>
)}
<p className='IBMSMSMBSSCL_CTOText'>
Reply Depth:&nbsp;<span>{size}</span>
{!isComplete && <Dots />}
</p>
</div>
{!isRoot && rootEvent && (
<Link
style={{
...(!isComplete ? { pointerEvents: 'none' } : {})
}}
className='btn btnMain IBMSMSMBSSCL_CTOBtn'
type='button'
to={baseUrl + rootEvent.encode()}
>
Main Post {!isComplete && <Dots />}
</Link>
)}
</div>
<div className='IBMSMSMBSSCL_CommentTop'>
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
<Link
className='IBMSMSMBSSCL_CommentTopPP'
to={profileRoute}
style={{
background: `url('${
profile?.image || ''
}') center / cover no-repeat`
}}
/>
</div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'>
<Link
className='IBMSMSMBSSCL_CTD_Name'
to={profileRoute}
>
{profile?.displayName || profile?.name || ''}{' '}
</Link>
<Link
className='IBMSMSMBSSCL_CTD_Address'
to={profileRoute}
>
{hexToNpub(event.pubkey)}
</Link>
</div>
{event.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'>
{formatDate(event.created_at * 1000, 'hh:mm aa')}{' '}
</a>
<a className='IBMSMSMBSSCL_CADDate'>
{formatDate(event.created_at * 1000, 'dd/MM/yyyy')}
</a>
</div>
)}
</div>
</div>
<div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={event.content} />
</div>
<div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...event.rawEvent()} />
{event.kind === NDKKind.Text && (
<>
{/* Quote Repost, Kind 1 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading
? undefined
: () => setShowQuoteRepostPopup(true)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : quoteRepostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showQuoteRepostPopup && (
<NoteQuoteRepostPopup
ndkEvent={event}
handleClose={() => setShowQuoteRepostPopup(false)}
/>
)}
{/* Repost, Kind 6 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading || hasReposted
? undefined
: () => setShowRepostPopup(true)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 -64 640 640'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : repostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showRepostPopup && (
<NoteRepostPopup
ndkEvent={event}
handleConfirm={handleRepost}
handleClose={() => setShowRepostPopup(false)}
/>
)}
</>
)}
{typeof profile?.lud16 !== 'undefined' &&
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
<span className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{commentEvents.length}
</p>
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
</span>
</div>
</div>
</div>
</div>
<div className='pUMCB_CommentToPrime'>
<div className='IBMSMSMBSSCC_Top'>
<textarea
ref={ref}
className='IBMSMSMBSSCC_Top_Box postSocialTextarea'
placeholder='Got something to say?'
value={replyText}
onChange={handleChange}
style={{ height: '0px' }}
></textarea>
</div>
<div className='IBMSMSMBSSCC_Bottom'>
{/* <a className='IBMSMSMBSSCC_BottomButton'>Quote-Repost</a> */}
<button
onClick={handleComment}
disabled={isSubmitting}
className='IBMSMSMBSSCC_BottomButton'
>
{isSubmitting ? 'Replying...' : 'Reply'}
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</button>
</div>
</div>
{commentEvents.length + quoteRepostEvents.length > 0 && (
<>
<h3 className='IBMSMSMBSSCL_CommentNoteRepliesTitle'>
{isNote ? (
<div className='dropdown'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{showQuoteReposts ? 'Quote-Reposts' : 'Replies'}
</button>
<div className='dropdown-menu dropdownMainMenu'>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() => setShowQuoteReposts(false)}
>
Replies
</div>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() => setShowQuoteReposts(true)}
>
Quote-Reposts
</div>
</div>
</div>
) : (
<>Replies</>
)}
<button
type='button'
className='btnMain IBMSMSMBSSCL_CommentNoteRepliesTitleBtn'
onClick={
discoveredCount ? handleDiscoveredClick : undefined
}
>
<span>
{isLoading ? (
<>
Discovering replies
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered replies</>
) : (
<>No new replies</>
)}
</span>
</button>
</h3>
{(showQuoteReposts
? quoteRepostEvents.length
: commentEvents.length) === 0 && !isLoading ? (
<div className='IBMSMListFeedNoPosts'>
<p>
There are no{' '}
{showQuoteReposts ? 'quote-reposts' : 'replies'} to show
</p>
</div>
) : (
<div className='pUMCB_RepliesToPrime'>
{showQuoteReposts
? quoteRepostEvents.map((reply) => (
<Comment
key={reply.id}
comment={{ event: reply }}
/>
))
: commentEvents
// Filter out events with 'q' tag since we are showing them with a dropdown
.filter((r) => r.event.tagValue('q') !== event.id)
.map((reply) => (
<Comment key={reply.event.id} comment={reply} />
))}
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -123,7 +123,3 @@ export const FALLBACK_PROFILE_IMAGE =
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
export const PROFILE_BLOG_FILTER_LIMIT = 20
export const MAX_VISIBLE_TEXT_PER_COMMENT = 500
export const CLIENT_NAME_VALUE = 'DEG Mods'
export const CLIENT_TAG_VALUE =
'31990:f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5:bf1987d6-b772-43c6-bce7-42b638a9ffed'

View File

@ -6,17 +6,11 @@ import NDK, {
NDKList,
NDKRelaySet,
NDKSubscriptionCacheUsage,
NDKSubscriptionOptions,
NDKUser,
zapInvoiceFromEvent
} from '@nostr-dev-kit/ndk'
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'
import {
CLIENT_NAME_VALUE,
CLIENT_TAG_VALUE,
MOD_FILTER_LIMIT,
T_TAG_VALUE
} from 'constants.ts'
import { MOD_FILTER_LIMIT, T_TAG_VALUE } from 'constants.ts'
import { Dexie } from 'dexie'
import { createContext, ReactNode, useEffect, useMemo } from 'react'
import { toast } from 'react-toastify'
@ -54,10 +48,7 @@ export interface NDKContextType {
hexKey: string,
userRelaysType: UserRelaysType
) => Promise<NDKEvent | null>
findMetadata: (
pubkey: string,
opts?: NDKSubscriptionOptions
) => Promise<UserProfile>
findMetadata: (pubkey: string) => Promise<UserProfile>
getTotalZapAmount: (
user: string,
eTag: string,
@ -120,20 +111,13 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
const ndk = useMemo(() => {
if (import.meta.env.MODE === 'development') {
localStorage.setItem('debug', '*')
} else {
localStorage.removeItem('debug')
}
localStorage.removeItem('debug')
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
dexieAdapter.locking = true
const ndk = new NDK({
enableOutboxModel: true,
autoConnectUserRelays: true,
autoFetchUserMutelist: true,
clientName: CLIENT_NAME_VALUE,
clientNip89: CLIENT_TAG_VALUE,
explicitRelayUrls: [
'wss://user.kindpag.es',
'wss://purplepag.es',
@ -268,7 +252,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
})
.catch((err) => {
log(
false, // Too many failed requests, turned off for clarity
true,
LogType.Error,
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
@ -325,16 +309,13 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
* @param hexKey - The pubkey to search for metadata.
* @returns A promise that resolves to the metadata event.
*/
const findMetadata = async (
pubkey: string,
opts?: NDKSubscriptionOptions
): Promise<UserProfile> => {
const findMetadata = async (pubkey: string): Promise<UserProfile> => {
const npub = hexToNpub(pubkey)
const user = new NDKUser({ npub })
user.ndk = ndk
const userProfile = await user.fetchProfile(opts)
const userProfile = await user.fetchProfile()
return userProfile
}
@ -388,14 +369,16 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
const publish = async (event: NDKEvent): Promise<string[]> => {
if (!event.sig) throw new Error('Before publishing first sign the event!')
try {
const res = await event.publish(undefined, 10000)
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
} catch (err) {
console.error(`An error occurred in publishing event`, err)
return []
}
return event
.publish(undefined, 10000)
.then((res) => {
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
})
.catch((err) => {
console.error(`An error occurred in publishing event`, err)
return []
})
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,19 +10,17 @@ import { useEffect, useState } from 'react'
import { CommentEvent, UserRelaysType } from 'types'
import { log, LogType, timeout } from 'utils'
import { useNDKContext } from './useNDKContext'
import _ from 'lodash'
export const useComments = (
author: string | undefined,
aTag: string | undefined,
eTag?: string | undefined
aTag: string | undefined
) => {
const { ndk } = useNDKContext()
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
useEffect(() => {
if (!(author && (aTag || eTag))) {
// Author and aTag/eTag are required
if (!(author && aTag)) {
// Author and aTag are required
return
}
@ -41,7 +39,7 @@ export const useComments = (
})
.catch((err) => {
log(
false, // Too many failed requests, turned off for clarity
true,
LogType.Error,
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
err
@ -50,17 +48,8 @@ export const useComments = (
})
const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.GenericReply],
...(aTag
? {
'#a': [aTag]
}
: {}),
...(eTag
? {
'#e': [eTag]
}
: {})
kinds: [NDKKind.Text],
'#a': [aTag]
}
const relayUrls = new Set<string>()
@ -83,57 +72,22 @@ export const useComments = (
)
subscription.on('event', (ndkEvent) => {
const eTags = ndkEvent.getMatchingTags('e')
const aTags = ndkEvent.getMatchingTags('a')
// This event is not a reply to, nor does it refer to any other event
if (!aTags.length && !eTags.length) return
setCommentEvents((prev) => {
if (ndkEvent.kind === NDKKind.Text) {
// Resolve comments with markers and positional "e" tags
// https://github.com/nostr-protocol/nips/blob/master/10.md
const root = ndkEvent.getMatchingTags('e', 'root')
const replies = ndkEvent.getMatchingTags('e', 'reply')
// This event has reply markers but does not match eTag
if (replies.length && !replies.some((e) => eTag === e[1])) {
return [...prev]
}
// This event has a single #e tag reference
// Checks single marked event (root) and a single positional "e" tags
// Allow if either old kind 1 reply to addressable or matches eTag
if (eTags.length === 1 && !(aTag || eTag === _.first(eTags)?.[1])) {
return [...prev]
}
// Position "e" tags (no markers)
// Multiple e tags, checks the last "e" tag
// Last "e" tag does not match eTag
if (
root.length + replies.length === 0 &&
eTags.length > 1 &&
_.last(eTags)?.[1] !== eTag
) {
return [...prev]
}
// "e" tags with markets
// Multiple replies, checks the last "e" tag
// Only show direct replies
if (replies.length > 1 && _.last(replies)?.[1] !== eTag) {
return [...prev]
}
}
// Event is already included
if (prev.find((comment) => comment.event.id === ndkEvent.id)) {
if (prev.find((e) => e.id === ndkEvent.id)) {
return [...prev]
}
// Event is a direct reply
return [{ event: ndkEvent }, ...prev]
const commentEvent: CommentEvent = {
kind: NDKKind.Text,
tags: ndkEvent.tags,
content: ndkEvent.content,
created_at: ndkEvent.created_at!,
pubkey: ndkEvent.pubkey,
id: ndkEvent.id,
sig: ndkEvent.sig!
}
return [commentEvent, ...prev]
})
})
@ -148,7 +102,7 @@ export const useComments = (
subscription.stop()
}
}
}, [aTag, author, eTag, ndk])
}, [aTag, author, ndk])
return {
commentEvents,

View File

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

View File

@ -5,14 +5,11 @@ import { Game } from 'types'
import { log, LogType } from 'utils'
import gameFiles from '../utils/games'
let cachedGamesData: Game[] | null = null
export const useGames = () => {
const hasProcessedFiles = useRef(false)
const [games, setGames] = useState<Game[]>(cachedGamesData || [])
const [games, setGames] = useState<Game[]>([])
useEffect(() => {
if (cachedGamesData) return
if (hasProcessedFiles.current) return
hasProcessedFiles.current = true
@ -55,7 +52,6 @@ export const useGames = () => {
})
await Promise.all(promises)
cachedGamesData = uniqueGames
setGames(uniqueGames)
} catch (err) {
log(

View File

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

View File

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

View File

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

View File

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

View File

@ -1,80 +0,0 @@
import {
NDKEvent,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { useMemo, useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
import _ from 'lodash'
export const useReplies = (eTag: string | undefined) => {
const { ndk } = useNDKContext()
const [replies, setReplies] = useState<NDKEvent[]>([])
const [isComplete, setIsComplete] = useState(false)
useDidMount(async () => {
if (!eTag) {
setIsComplete(true)
return
}
let eDepth: string | undefined = eTag
while (eDepth) {
const previousReply = await ndk.fetchEvent(
{
kinds: [NDKKind.Text, NDKKind.GenericReply],
ids: [eDepth]
},
{
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
}
)
if (previousReply) {
setReplies((p) => {
if (p.findIndex((p) => p.id === previousReply.id) === -1) {
p.push(previousReply)
}
return [...p]
})
const eTags = previousReply.getMatchingTags('e')
eDepth = _.last(eTags)?.[1]
} else {
eDepth = undefined
}
}
setIsComplete(true)
})
const rootEvent = useMemo(() => {
let rootETag: string | undefined
for (const r of replies) {
const root = r.getMatchingTags('e', 'root')
if (root.length) {
rootETag = root[0][1]
break
}
}
const rootEvent = replies.find((r) => r.id === rootETag)
// Root: first reply/comment, target of "Main Post"
// - addr: first comment
// - notes: root comment (marker)
return rootEvent
? rootEvent
: isComplete
? replies[replies.length - 1]
: undefined
}, [isComplete, replies])
const parentEvent = useMemo(() => {
return replies.length > 0 ? replies[0] : undefined
}, [replies])
return {
size: replies.length,
isComplete,
parent: parentEvent,
root: rootEvent
}
}

View File

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

View File

@ -2,16 +2,10 @@ import { useEffect } from 'react'
export const useBodyScrollDisable = (disable: boolean) => {
useEffect(() => {
const initialOverflow = document.body.style.overflow
if (disable && initialOverflow !== 'hidden') {
document.body.style.overflow = 'hidden'
}
if (disable) document.body.style.overflow = 'hidden'
return () => {
if (initialOverflow !== 'hidden') {
document.body.style.overflow = initialOverflow
}
document.body.style.overflow = ''
}
}, [disable])
}

View File

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

View File

@ -1,54 +0,0 @@
import { MAX_VISIBLE_TEXT_PER_COMMENT } from '../constants'
import { useState } from 'react'
interface UseTextLimitResult {
text: string
isTextOverflowing: boolean
isExpanded: boolean
toggle: () => void
}
export const useTextLimit = (
text: string,
limit: number = MAX_VISIBLE_TEXT_PER_COMMENT
): UseTextLimitResult => {
const [isExpanded, setIsExpanded] = useState(false)
const getFilteredTextAndIndices = (): {
filteredText: string
indices: number[]
} => {
const words = text.split(/[\s\n]+/)
const filteredWords: string[] = []
const indices: number[] = []
let currentIndex = 0
words.forEach((word) => {
if (!(word.startsWith('nostr:') || word.startsWith('http'))) {
filteredWords.push(word)
indices.push(currentIndex)
}
currentIndex += word.length + 1
})
return { filteredText: filteredWords.join(' '), indices }
}
const { filteredText, indices } = getFilteredTextAndIndices()
const isTextOverflowing = filteredText.length > limit
const getUpdatedText = (): string => {
if (isExpanded || !isTextOverflowing) {
return text
}
const sliceEndIndex = indices.find((index) => index >= limit) || limit
return text.slice(0, sliceEndIndex) + '…'
}
return {
text: getUpdatedText(),
isTextOverflowing,
isExpanded,
toggle: () => setIsExpanded((prev) => !prev)
}
}

View File

@ -34,6 +34,15 @@ body {
box-sizing: border-box;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
/*
h1 {
font-size: 3.2em;

View File

@ -1,41 +1,10 @@
import { Profile } from 'components/ProfileSection'
import { useAppSelector } from 'hooks'
import { Navigate, Outlet } from 'react-router-dom'
import { appRoutes } from 'routes'
import { Outlet } from 'react-router-dom'
export const FeedLayout = () => {
const userState = useAppSelector((state) => state.user)
if (!userState.user?.pubkey) return <Navigate to={appRoutes.home} />
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainFullSide'>
<div className='IBMSMSplitMainFullSideFeedWrapper'>
<div className='IBMSMSplitMainFullSideFWSide'>
{userState.auth && userState.user?.pubkey && (
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSec'>
<Profile pubkey={userState.user.pubkey as string} />
</div>
</div>
)}
</div>
<div className='IBMSMSplitMainFullSideFWMid'>
<Outlet />
</div>
<div className='IBMSMSplitMainFullSideFWSide'>
<div className='IBMSMSplitMainSmallSideSecWrapper'>
<div className='IBMSMSplitMainSmallSideSecBox'><p>This is a box</p></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h1>WIP</h1>
<Outlet />
</div>
)
}

View File

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

View File

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

View File

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

View File

@ -43,18 +43,14 @@ export const SocialNav = () => {
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'
/>
{!!userState.auth && (
<>
<NavButton
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.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.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.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}
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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,7 @@ import {
useLoaderData,
Link as ReactRouterLink,
useNavigation,
useSubmit,
Outlet,
useParams,
useNavigate
useSubmit
} from 'react-router-dom'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ProfileSection } from 'components/ProfileSection'
@ -18,12 +15,9 @@ import { Interactions } from 'components/Internal/Interactions'
import { BlogCard } from 'components/BlogCard'
import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable, useLocalStorage } from 'hooks'
import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { ReportPopup } from 'components/ReportPopup'
import { Viewer } from 'components/Markdown/Viewer'
import { PostWarnings } from 'components/PostWarning'
import { appRoutes } from 'routes'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -35,14 +29,12 @@ const BLOG_REPORT_REASONS = [
]
export const BlogPage = () => {
const { nevent } = useParams()
const { blog, latest, isAddedToNSFW, isBlocked, postWarning } =
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 isLoggedIn = userState.auth && userState.user?.pubkey !== 'undefined'
const navigation = useNavigation()
const [commentCount, setCommentCount] = useState(0)
@ -82,17 +74,6 @@ export const BlogPage = () => {
}
}
const navigate = useNavigate()
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(
(blog?.nsfw ?? false) && !confirmNsfw
)
const handleConfirm = (confirm: boolean) => {
if (!confirm) {
navigate(appRoutes.home)
}
}
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
@ -102,7 +83,6 @@ export const BlogPage = () => {
<>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'>
{postWarning && <PostWarnings type={postWarning} />}
<div className='IBMSMSMBSSPost'>
<div
className='dropdown dropdownMain dropdownMainBlogpost'
@ -192,25 +172,23 @@ export const BlogPage = () => {
Share
</a>
{isLoggedIn && (
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(Date.now())}
<a
className='dropdown-item dropdownMainMenuItem'
id='reportPost'
onClick={() => setShowReportPopUp(Date.now())}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<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>
)}
<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'
@ -329,13 +307,6 @@ export const BlogPage = () => {
</>
)}
{!!blog?.author && <ProfileSection pubkey={blog.author} />}
<Outlet key={nevent} />
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</div>
</div>
</div>

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useLoaderData, useNavigation, useSearchParams } from 'react-router-dom'
import { useLocalStorage } from 'hooks'
import { BlogCardDetails, NSFWFilter, SortBy } from 'types'
@ -9,7 +9,7 @@ import '../../styles/pagination.css'
import '../../styles/search.css'
import '../../styles/styles.css'
import { PaginationWithPageNumbers } from 'components/Pagination'
import { normalizeSearchString, scrollIntoView } from 'utils'
import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { Filter } from 'components/Filters'
import { Dropdown } from 'components/Filters/Dropdown'
@ -28,15 +28,9 @@ export const BlogsPage = () => {
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
// Keep the states synced with the URL
const q = searchParams.get('q')
if (searchTermRef.current) searchTermRef.current.value = q ?? ''
setSearchTerm(q ?? '')
}, [searchParams])
const [searchTerm, setSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
const handleSearch = () => {
const value = searchTermRef.current?.value ?? '' // Access the input value from the ref
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
@ -49,17 +43,6 @@ export const BlogsPage = () => {
replace: true
})
}
// Reset search automatically when deleting last entry
const handleChange = () => {
const value = searchTermRef.current?.value ?? '' // Access the input value from the ref
if (!value && searchParams.get('q')) {
searchParams.delete('q')
setSearchParams(searchParams, {
replace: true
})
handleSearch()
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch()
@ -80,17 +63,15 @@ export const BlogsPage = () => {
}
let filtered = blogs?.filter(filterNsfwFn) || []
const normalizedSearchTerm = normalizeSearchString(searchTerm)
const lowerCaseSearchTerm = searchTerm.toLowerCase()
if (normalizedSearchTerm !== '') {
if (searchTerm !== '') {
const filterSearchTermFn = (blog: Partial<BlogCardDetails>) =>
normalizeSearchString(blog.title || '').includes(
normalizedSearchTerm
) ||
(blog.summary || '').toLowerCase().includes(normalizedSearchTerm) ||
(blog.content || '').toLowerCase().includes(normalizedSearchTerm) ||
(blog.title || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.summary || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.content || '').toLowerCase().includes(lowerCaseSearchTerm) ||
(blog.tTags || []).findIndex((tag) =>
tag.toLowerCase().includes(normalizedSearchTerm)
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
filtered = filtered.filter(filterSearchTermFn)
}
@ -143,7 +124,6 @@ export const BlogsPage = () => {
ref={searchTermRef}
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
handleChange={handleChange}
/>
</div>
</div>

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