Compare commits

..

39 Commits

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

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

View File

@ -7,14 +7,8 @@ VITE_ADMIN_NPUBS= <A comma separated list of npubs>
# A dedicated npub used for reporting mods, blogs, profile and etc.
VITE_REPORTING_NPUB= <npub1...>
# A dedicated npub used for site WOT.
VITE_SITE_WOT_NPUB= <npub1...>
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
# A comma separated list of npubs, this list is used to fetch just the posts from the admin
VITE_BLOG_NPUBS= <A comma separated list of npubs>
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png

View File

@ -1,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

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

View File

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

View File

@ -32,11 +32,9 @@ jobs:
run: |
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
cat .env
- name: Build
run: npm run build

View File

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

4647
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "degmods.com",
"private": true,
"version": "0.0.0-alpha-1",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -11,12 +11,15 @@
},
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.11.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.6.6",
"@tiptap/extension-link": "2.6.6",
"@tiptap/react": "2.6.6",
"@tiptap/starter-kit": "2.6.6",
"@types/react-helmet": "^6.1.11",
"axios": "^1.7.9",
"axios": "1.7.3",
"bech32": "2.0.0",
"buffer": "6.0.3",
"date-fns": "3.6.0",
@ -25,8 +28,6 @@
"file-saver": "2.0.5",
"fslightbox-react": "1.7.6",
"lodash": "4.17.21",
"marked": "^14.1.3",
"marked-directive": "^1.0.7",
"nostr-login": "1.5.2",
"nostr-tools": "2.7.1",
"papaparse": "5.4.1",
@ -34,7 +35,6 @@
"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",
@ -53,7 +53,6 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "1.8.8",
"@types/turndown": "^5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
[
{ "name": "gameplay ", "sub": ["difficulty"] },
{ "name": "input", "sub": ["key mapping", "macro"] },
{
"name": "visual",
"sub": ["textures", "lighting", "character models", "environment models"]
},
{ "name": "audio", "sub": ["sfx", "music", "voice"] },
{ "name": "user interface", "sub": ["hud", "menu"] },
{
"name": "quality of life",
"sub": ["bug fixes", "performance", "accessibility"]
},
"total conversions",
"translation",
"multiplayer",
"clothing",
"mod manager"
]

View File

@ -1,14 +1,5 @@
Game Name,16 by 9 image,Boxart image
(Unlisted Game),,
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
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
Game Name,16 by 9 image,Boxart image
(Unlisted Game),,
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
1 Game Name 16 by 9 image Boxart image
2 (Unlisted Game)
3 Minecraft https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
4 Vintage Story https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
5 Yandere Simulator https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 277 KiB

View File

@ -1,72 +0,0 @@
import { createPortal } from 'react-dom'
import { AlertPopupProps } from 'types'
export const AlertPopup = ({
header,
label,
handleConfirm,
handleClose
}: AlertPopupProps) => {
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>{header}</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' }}
>
{label}
</label>
</div>
<div
style={{
display: 'flex',
width: '100%',
gap: '10px'
}}
>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => handleConfirm(true)}
>
Yes
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => handleConfirm(false)}
>
No
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

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

View File

@ -1,333 +0,0 @@
import { useLocalStorage } from 'hooks'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { getGamePageRoute } from 'routes'
import { ModFormState, Categories, Category } from 'types'
import {
getCategories,
flattenCategories,
addToUserCategories,
capitalizeEachWord
} from 'utils'
interface CategoryAutocompleteProps {
game: string
LTags: string[]
setFormState: (value: React.SetStateAction<ModFormState>) => void
}
export const CategoryAutocomplete = ({
game,
LTags,
setFormState
}: CategoryAutocompleteProps) => {
// Fetch the hardcoded categories from assets
const flattenedCategories = useMemo(() => getCategories(), [])
// Fetch the user categories from local storage
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const flattenedUserCategories = useMemo(
() => flattenCategories(userHierarchies, []),
[userHierarchies]
)
// Create options and select categories from the mod LTags (hierarchies)
const { selectedCategories, combinedOptions } = useMemo(() => {
const combinedCategories = [
...flattenedCategories,
...flattenedUserCategories
]
const hierarchies = LTags.map((hierarchy) => {
const existingCategory = combinedCategories.find(
(cat) => cat.hierarchy === hierarchy.replace(/:/g, ' > ')
)
if (existingCategory) {
return existingCategory
} else {
const segments = hierarchy.split(':')
const lastSegment = segments[segments.length - 1]
return { name: lastSegment, hierarchy: hierarchy, l: [lastSegment] }
}
})
// Selected categorires (based on the LTags)
const selectedCategories = Array.from(new Set([...hierarchies]))
// Combine user, predefined category hierarchies and selected values (LTags in case some are missing)
const combinedOptions = Array.from(
new Set([...combinedCategories, ...selectedCategories])
)
return { selectedCategories, combinedOptions }
}, [LTags, flattenedCategories, flattenedUserCategories])
const [inputValue, setInputValue] = useState<string>('')
const filteredOptions = useMemo(
() =>
combinedOptions.filter((option) =>
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
),
[combinedOptions, inputValue]
)
const getSelectedCategories = (cats: Categories[]) => {
const uniqueValues = new Set(
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
)
const concatenatedValue = Array.from(uniqueValues)
return concatenatedValue
}
const getSelectedHierarchy = (cats: Categories[]) => {
const hierarchies = cats.reduce<string[]>(
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
[]
)
const concatenatedValue = Array.from(hierarchies)
return concatenatedValue
}
const handleReset = () => {
setFormState((prevState) => ({
...prevState,
['lTags']: [],
['LTags']: []
}))
setInputValue('')
}
const handleRemove = (option: Categories) => {
const updatedCategories = selectedCategories.filter(
(cat) => cat.hierarchy !== option.hierarchy
)
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
}
const handleSelect = (option: Categories) => {
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
const updatedCategories = [...selectedCategories, option]
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
}
setInputValue('')
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleAddNew = () => {
if (inputValue) {
const value = inputValue.trim().toLowerCase()
const values = value.split('>').map((s) => s.trim())
const newOption: Categories = {
name: value,
hierarchy: value,
l: values
}
setUserHierarchies((prev) => {
addToUserCategories(prev, value)
return [...prev]
})
const updatedCategories = [...selectedCategories, newOption]
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(updatedCategories),
['LTags']: getSelectedHierarchy(updatedCategories)
}))
setInputValue('')
}
}
const handleAddNewCustom = (option: Categories) => {
setUserHierarchies((prev) => {
addToUserCategories(prev, option.hierarchy)
return [...prev]
})
}
const Row = ({ index }: { index: number }) => {
return (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={() => handleSelect(filteredOptions[index])}
>
{capitalizeEachWord(filteredOptions[index].hierarchy)}
{/* Show "Remove" button when the category is selected */}
{selectedCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
type='button'
className='btn btnMain btnMainInsideField btnMainRemove'
onClick={() => handleRemove(filteredOptions[index])}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
</svg>
</button>
)}
{/* Show "Add" button when the category is not included in the predefined or userdefined lists */}
{!flattenedCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) &&
!flattenedUserCategories.some(
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
onClick={() => handleAddNewCustom(filteredOptions[index])}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
)}
</div>
)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Categories</label>
<p className='labelDescriptionMain'>You can select multiple categories</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<input
type='text'
className='inputMain inputMainWithBtn dropdown-toggle'
placeholder='Select some categories...'
data-bs-toggle='dropdown'
value={inputValue}
onChange={handleInputChange}
/>
<button
className='btn btnMain btnMainInsideField btnMainRemove'
title='Remove'
type='button'
onClick={handleReset}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
</svg>
</button>
<div
className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt category'
style={{
maxHeight: '500px'
}}
>
{filteredOptions.length > 0 ? (
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
) : (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={handleAddNew}
>
{inputValue &&
!filteredOptions?.find(
(option) =>
option.hierarchy.toLowerCase() === inputValue.toLowerCase()
) ? (
<>
Add "{inputValue}"
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
</>
) : (
<>No matches</>
)}
</div>
)}
</div>
</div>
</div>
{LTags.length > 0 && (
<div className='IBMSMSMBSSCategories'>
{LTags.map((hierarchy) => {
const hierarchicalCategories = hierarchy.split(`:`)
const categories = hierarchicalCategories
.map<React.ReactNode>((c, i) => {
const partialHierarchy = hierarchicalCategories
.slice(0, i + 1)
.join(':')
return game ? (
<Link
key={`category-${i}`}
target='_blank'
to={{
pathname: getGamePageRoute(game),
search: `h=${partialHierarchy}`
}}
className='IBMSMSMBSSCategoriesBoxItem'
>
<p>{capitalizeEachWord(c)}</p>
</Link>
) : (
<p className='IBMSMSMBSSCategoriesBoxItem'>
{capitalizeEachWord(c)}
</p>
)
})
.reduce((prev, curr, i) => [
prev,
<div
key={`separator-${i}`}
className='IBMSMSMBSSCategoriesBoxSeparator'
>
<p>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
)
}

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

@ -1,148 +0,0 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { Filter } from '.'
import { NsfwFilterOptions } from './NsfwFilterOptions'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const BlogsFilter = React.memo(
({ author, filterKey = 'filter-blog' }: Props) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</Dropdown>
{/* moderation filter options */}
<Dropdown label={filterOptions.moderated}>
{Object.values(ModeratedFilter).map((item) => {
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
if (!(isAdmin || isOwnProfile)) return null
}
return (
<Option
key={`sort-${item}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
return null
}
// when logged in user not admin
if (
item === WOTFilterOptions.None ||
item === WOTFilterOptions.Mine_Only ||
item === WOTFilterOptions.Exclude
) {
const isWoTNpub =
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isWoTNpub || isOwnProfile)) return null
}
return (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* source filter options */}
<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>
</Filter>
)
}
)

View File

@ -1,3 +0,0 @@
.noResult:not(:only-child) {
display: none;
}

View File

@ -1,550 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Category } from 'types'
import {
addToUserCategories,
capitalizeEachWord,
deleteFromUserCategories,
flattenCategories
} from 'utils'
import { useLocalStorage } from 'hooks'
import { useSearchParams } from 'react-router-dom'
import styles from './CategoryFilterPopup.module.scss'
import categoriesData from './../../assets/categories/categories.json'
interface CategoryFilterPopupProps {
categories: string[]
setCategories: React.Dispatch<React.SetStateAction<string[]>>
hierarchies: string[]
setHierarchies: React.Dispatch<React.SetStateAction<string[]>>
handleClose: () => void
}
export const CategoryFilterPopup = ({
categories,
setCategories,
hierarchies,
setHierarchies,
handleClose
}: CategoryFilterPopupProps) => {
const [searchParams, setSearchParams] = useSearchParams()
const linkedHierarchy = searchParams.get('h')
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const [filterCategories, setFilterCategories] = useState(categories)
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
const handleApply = () => {
// Update selection with linked category if it exists
if (linkedHierarchy !== null && linkedHierarchy !== '') {
// Combine existing selection with the linked
setFilterHierarchies((prev) => {
prev.push(linkedHierarchy)
const newFilterHierarchies = Array.from(new Set([...prev]))
setHierarchies(newFilterHierarchies)
return newFilterHierarchies
})
// Clear hierarchy link in search params
searchParams.delete('h')
setSearchParams(searchParams)
} else {
setHierarchies(filterHierarchies)
}
setCategories(filterCategories)
}
const [inputValue, setInputValue] = useState<string>('')
const userHierarchiesMatching = useMemo(
() =>
flattenCategories(userHierarchies, []).some((h) =>
h.hierarchy.includes(inputValue.toLowerCase())
),
[inputValue, userHierarchies]
)
// const hierarchiesMatching = useMemo(
// () =>
// flattenCategories(categoriesData, []).some((h) =>
// h.hierarchy.includes(inputValue.toLowerCase())
// ),
// [inputValue]
// )
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleSingleSelection = (category: string, isSelected: boolean) => {
let updatedCategories = [...filterCategories]
if (isSelected) {
updatedCategories.push(category)
} else {
updatedCategories = updatedCategories.filter((item) => item !== category)
}
setFilterCategories(updatedCategories)
}
const handleCombinationSelection = (path: string[], isSelected: boolean) => {
const pathString = path.join(':')
let updatedHierarchies = [...filterHierarchies]
if (isSelected) {
updatedHierarchies.push(pathString)
} else {
updatedHierarchies = updatedHierarchies.filter(
(item) => item !== pathString
)
}
setFilterHierarchies(updatedHierarchies)
}
const handleAddNew = () => {
if (inputValue) {
const value = inputValue.toLowerCase()
const values = value
.trim()
.split('>')
.map((s) => s.trim())
setUserHierarchies((prev) => {
addToUserCategories(prev, value)
return [...prev]
})
const path = values.join(':')
// Add new hierarchy to current selection and active selection
// Convert through set to remove duplicates
setFilterHierarchies((prev) => {
prev.push(path)
return Array.from(new Set([...prev]))
})
setHierarchies((prev) => {
prev.push(path)
return Array.from(new Set([...prev]))
})
setInputValue('')
}
}
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Categories filter</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' }}
>
Choose categories...
</label>
<p className='labelDescriptionMain'>
Choose one or more pre-definied or custom categories to filter out mods with.
</p>
</div>
<input
type='text'
className='inputMain inputMainWithBtn'
placeholder='Select some categories...'
value={inputValue}
onChange={handleInputChange}
/>
{userHierarchies.length > 0 && (
<>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
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>
</div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
{!userHierarchiesMatching && <div>No results.</div>}
{userHierarchies
.filter((c) => typeof c !== 'string')
.map((c, i) => (
<CategoryCheckbox
key={`${c}_${i}`}
inputValue={inputValue}
category={c}
path={[c.name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={
handleCombinationSelection
}
selectedSingles={filterCategories}
selectedCombinations={filterHierarchies}
handleRemove={(path) => {
setUserHierarchies((prev) => {
deleteFromUserCategories(prev, path.join('>'))
return [...prev]
})
// Remove the deleted hierarchies from current filter selection and active selection
setFilterHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
setHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
}}
/>
))}
</div>
</>
)}
<div className='inputLabelWrapperMain'>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Categories
</label>
<p className='labelDescriptionMain'>Here&apos;s where you select any of the pre-defined categories</p>
</div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
<div className={`${styles.noResult}`}>
<div>No results.</div>
<br />
{userHierarchiesMatching ? (
<div>Already defined in your categories</div>
) : (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
onClick={handleAddNew}
>
Add and search for "{inputValue}" category
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
</div>
)}
</div>
{(categoriesData as Category[]).map((category) => {
const name =
typeof category === 'string' ? category : category.name
return (
<CategoryCheckbox
key={name}
inputValue={inputValue}
category={category}
path={[name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={
handleCombinationSelection
}
selectedSingles={filterCategories}
selectedCombinations={filterHierarchies}
/>
)
})}
</div>
</div>
<div
style={{
display: 'flex',
width: '100%',
gap: '10px'
}}
>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={handleClose}
>
Cancel
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => {
// Clear the linked hierarchy
searchParams.delete('h')
setSearchParams(searchParams)
// Clear current filters
setFilterCategories([])
setFilterHierarchies([])
}}
>
Reset
</button>
<button
className='btn btnMain btnMainPopup'
type='button'
onPointerDown={() => {
handleApply()
handleClose()
}}
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}
interface CategoryCheckboxProps {
inputValue: string
category: Category | string
path: string[]
handleSingleSelection: (category: string, isSelected: boolean) => void
handleCombinationSelection: (path: string[], isSelected: boolean) => void
selectedSingles: string[]
selectedCombinations: string[]
indentLevel?: number
handleRemove?: (path: string[]) => void
}
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
inputValue,
category,
path,
handleSingleSelection,
handleCombinationSelection,
selectedSingles,
selectedCombinations,
indentLevel = 0,
handleRemove
}) => {
const [searchParams, setSearchParams] = useSearchParams()
const linkedHierarchy = searchParams.get('h')
const name = typeof category === 'string' ? category : category.name
const hierarchy = path.join(' > ').toLowerCase()
const isMatching = hierarchy.includes(inputValue.toLowerCase())
const isLinked =
linkedHierarchy !== null &&
hierarchy === linkedHierarchy.replace(/:/g, ' > ')
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
const [isCombinationChecked, setIsCombinationChecked] =
useState<boolean>(false)
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
useEffect(() => {
const pathString = path.join(':')
setIsSingleChecked(selectedSingles.includes(name))
setIsCombinationChecked(selectedCombinations.includes(pathString))
// Recursive function to gather all descendant paths
const collectChildPaths = (
category: string | Category,
basePath: string[]
) => {
if (!category.sub || !Array.isArray(category.sub)) {
return []
}
let paths: string[] = []
for (const sub of category.sub) {
const subPath =
typeof sub === 'string'
? [...basePath, sub].join(':')
: [...basePath, sub.name].join(':')
paths.push(subPath)
if (typeof sub === 'object') {
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
}
}
return paths
}
const childPaths = collectChildPaths(category, path)
const anyChildCombinationSelected = childPaths.some((childPath) =>
selectedCombinations.includes(childPath)
)
const anyChildCombinationLinked = childPaths.some(
(childPath) =>
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
)
setIsIndeterminate(
(anyChildCombinationSelected || anyChildCombinationLinked) &&
!selectedCombinations.includes(pathString)
)
}, [
category,
linkedHierarchy,
name,
path,
selectedCombinations,
selectedSingles
])
const handleSingleChange = () => {
setIsSingleChecked(!isSingleChecked)
handleSingleSelection(name, !isSingleChecked)
}
const handleCombinationChange = () => {
// If combination is linked, clicking it again we will delete it
if (isLinked) {
searchParams.delete('h')
setSearchParams(searchParams)
} else {
setIsCombinationChecked(!isCombinationChecked)
handleCombinationSelection(path, !isCombinationChecked)
}
}
return (
<>
{isMatching && (
<div
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
style={{
marginLeft: `${indentLevel * 20}px`,
width: `calc(100% - ${indentLevel * 20}px)`
}}
>
<div
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
style={{
overflow: 'hidden'
}}
>
<input
id={name}
type='checkbox'
ref={(input) => {
if (input) {
input.indeterminate = isIndeterminate
}
}}
className={`CheckboxMain ${
isIndeterminate ? 'CheckboxIndeterminate' : ''
}`}
checked={isCombinationChecked || isLinked}
onChange={handleCombinationChange}
/>
<label
htmlFor={name}
className='form-label labelMain labelMainCategory'
>
{capitalizeEachWord(name)}
</label>
<input
style={{
display: 'none'
}}
id={name}
type='checkbox'
className='CheckboxMain'
name={name}
checked={isSingleChecked}
onChange={handleSingleChange}
/>
{typeof handleRemove === 'function' && (
<button
className='btn btnMain btnMainInsideField btnMainRemove'
title='Remove'
type='button'
onClick={() => handleRemove(path)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
</svg>
</button>
)}
</div>
</div>
)}
{typeof category !== 'string' &&
category.sub &&
Array.isArray(category.sub) && (
<>
{category.sub.map((subCategory) => {
if (typeof subCategory === 'string') {
return (
<CategoryCheckbox
inputValue={inputValue}
key={`${category.name}-${subCategory}`}
category={{ name: subCategory }}
path={[...path, subCategory]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={handleCombinationSelection}
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
handleRemove={handleRemove}
/>
)
} else {
return (
<CategoryCheckbox
inputValue={inputValue}
key={subCategory.name}
category={subCategory}
path={[...path, subCategory.name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={handleCombinationSelection}
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
handleRemove={handleRemove}
/>
)
}
})}
</>
)}
</>
)
}

View File

@ -1,25 +0,0 @@
import { PropsWithChildren } from 'react'
interface DropdownProps {
label: React.ReactNode
}
export const Dropdown = ({
label,
children
}: PropsWithChildren<DropdownProps>) => {
return (
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{label}
</button>
<div className='dropdown-menu dropdownMainMenu'>{children}</div>
</div>
</div>
)
}

View File

@ -1,176 +0,0 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React, { PropsWithChildren } from 'react'
import {
FilterOptions,
SortBy,
ModeratedFilter,
WOTFilterOptions,
RepostFilter
} from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Filter } from '.'
import { Dropdown } from './Dropdown'
import { Option } from './Option'
import { NsfwFilterOptions } from './NsfwFilterOptions'
type Props = {
author?: string | undefined
filterKey?: string | undefined
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
return (
<Filter>
{/* sort filter options */}
<Dropdown label={filterOptions.sort}>
{Object.values(SortBy).map((item, index) => (
<Option
key={`sortByItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* 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 isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isAdmin || isOwnProfile)) return null
}
return (
<Option
key={`moderatedFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* wot filter options */}
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
{Object.values(WOTFilterOptions).map((item, index) => {
// when user is not logged in
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
return null
}
// when logged in user not admin
if (
item === WOTFilterOptions.None ||
item === WOTFilterOptions.Mine_Only ||
item === WOTFilterOptions.Exclude
) {
const isWoTNpub =
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
const isOwnProfile =
author && userState.auth && userState.user?.pubkey === author
if (!(isWoTNpub || isOwnProfile)) return null
}
return (
<Option
key={`wotFilterOption-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
wot: item
}))
}
>
{item}
</Option>
)
})}
</Dropdown>
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* repost filter options */}
<Dropdown label={filterOptions.repost}>
{Object.values(RepostFilter).map((item, index) => (
<Option
key={`repostFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
repost: item
}))
}
>
{item}
</Option>
))}
</Dropdown>
{/* source filter options */}
<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>
{children}
</Filter>
)
}
)

View File

@ -1,64 +0,0 @@
import { FilterOptions, NSFWFilter } from 'types'
import { Option } from './Option'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
import { useState } from 'react'
import { useLocalStorage } from 'hooks'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
interface NsfwFilterOptionsProps {
filterKey: string
}
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
const [selectedNsfwOption, setSelectedNsfwOption] = useState<
NSFWFilter | undefined
>()
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const handleConfirm = (confirm: boolean) => {
if (confirm && selectedNsfwOption) {
setFilterOptions((prev) => ({
...prev,
nsfw: selectedNsfwOption
}))
}
}
return (
<>
{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}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</>
)
}

View File

@ -1,16 +0,0 @@
import { PropsWithChildren } from 'react'
interface OptionProps {
onClick: React.MouseEventHandler<HTMLDivElement>
}
export const Option = ({
onClick,
children
}: PropsWithChildren<OptionProps>) => {
return (
<div className='dropdown-item dropdownMainMenuItem' onClick={onClick}>
{children}
</div>
)
}

View File

@ -1,9 +0,0 @@
import { PropsWithChildren } from 'react'
export const Filter = ({ children }: PropsWithChildren) => {
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>{children}</div>
</div>
)
}

290
src/components/Inputs.tsx Normal file
View File

@ -0,0 +1,290 @@
import Link from '@tiptap/extension-link'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useEffect } from 'react'
import '../styles/styles.css'
import '../styles/tiptap.scss'
interface InputFieldProps {
label: string
description?: string
type?: 'text' | 'textarea' | 'richtext'
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onChange: (name: string, value: string) => void
}
export const InputField = React.memo(
({
label,
description,
type = 'text',
placeholder,
name,
inputMode,
value,
error,
onChange
}: InputFieldProps) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(name, e.target.value)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
{type === 'textarea' ? (
<textarea
className='inputMain'
placeholder={placeholder}
name={name}
value={value}
onChange={handleChange}
></textarea>
) : type === 'richtext' ? (
<RichTextEditor
content={value}
updateContent={(content) => onChange(name, content)}
/>
) : (
<input
type={type}
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
)}
{error && <InputError message={error} />}
</div>
)
}
)
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
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const CheckboxField = React.memo(
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label className='form-label labelMain'>{label}</label>
<input
type='checkbox'
className='CheckboxMain'
name={name}
checked={isChecked}
onChange={handleChange}
/>
</div>
)
)
type RichTextEditorProps = {
content: string
updateContent: (updatedContent: string) => void
}
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
const editor = useEditor({
extensions: [StarterKit, Link],
onUpdate: ({ editor }) => {
// Update the state when the editor content changes
updateContent(editor.getHTML())
},
content
})
// Update editor content when the `content` prop changes
useEffect(() => {
if (editor && editor.getHTML() !== content) {
editor.commands.setContent(content, false)
}
}, [content, editor])
return (
<div className='inputMain'>
{editor && (
<>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</>
)}
</div>
)
}
type MenuBarProps = {
editor: Editor
}
const MenuBar = ({ editor }: MenuBarProps) => {
const setLink = () => {
// Prompt the user to enter a URL
let url = prompt('URL')
// Check if the user provided a URL
if (url) {
// If the URL doesn't start with 'http://' or 'https://',
// prepend 'https://' to the URL
if (!/^(http|https):\/\//i.test(url)) {
url = `https://${url}`
}
return editor.chain().focus().setLink({ href: url }).run()
}
// If no URL was provided (e.g., the user cancels the prompt),
// return false, indicating that the link was not set.
return false
}
const unsetLink = () => editor.chain().focus().unsetLink().run()
const buttons: MenuBarButtonProps[] = [
{
label: 'Bold',
disabled: !editor.can().chain().focus().toggleBold().run(),
isActive: editor.isActive('bold'),
onClick: () => editor.chain().focus().toggleBold().run()
},
{
label: 'Italic',
disabled: !editor.can().chain().focus().toggleItalic().run(),
isActive: editor.isActive('italic'),
onClick: () => editor.chain().focus().toggleItalic().run()
},
{
label: 'Strike',
disabled: !editor.can().chain().focus().toggleStrike().run(),
isActive: editor.isActive('strike'),
onClick: () => editor.chain().focus().toggleStrike().run()
},
{
label: 'Clear marks',
onClick: () => editor.chain().focus().unsetAllMarks().run()
},
{
label: 'Clear nodes',
onClick: () => editor.chain().focus().clearNodes().run()
},
{
label: 'Paragraph',
isActive: editor.isActive('paragraph'),
onClick: () => editor.chain().focus().toggleStrike().run()
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
label: `H${level}`,
isActive: editor.isActive('heading', { level }),
onClick: () => editor.chain().focus().toggleHeading({ level }).run()
})),
{
label: 'Bullet list',
isActive: editor.isActive('bulletList'),
onClick: () => editor.chain().focus().toggleBulletList().run()
},
{
label: 'Ordered list',
isActive: editor.isActive('orderedList'),
onClick: () => editor.chain().focus().toggleOrderedList().run()
},
{
label: 'Code block',
isActive: editor.isActive('codeBlock'),
onClick: () => editor.chain().focus().toggleCodeBlock().run()
},
{
label: 'Blockquote',
isActive: editor.isActive('blockquote'),
onClick: () => editor.chain().focus().toggleBlockquote().run()
},
{
label: 'Link',
isActive: editor.isActive('link'),
onClick: editor.isActive('link') ? unsetLink : setLink
},
{
label: 'Horizontal rule',
onClick: () => editor.chain().focus().setHorizontalRule().run()
},
{
label: 'Hard break',
onClick: () => editor.chain().focus().setHardBreak().run()
},
{
label: 'Undo',
disabled: !editor.can().chain().focus().undo().run(),
onClick: () => editor.chain().focus().undo().run()
},
{
label: 'Redo',
disabled: !editor.can().chain().focus().redo().run(),
onClick: () => editor.chain().focus().redo().run()
}
]
return (
<div className='control-group'>
<div className='button-group'>
{buttons.map(({ label, disabled, isActive, onClick }) => (
<MenuBarButton
key={label}
label={label}
disabled={disabled}
isActive={isActive}
onClick={onClick}
/>
))}
</div>
</div>
)
}
interface MenuBarButtonProps {
label: string
isActive?: boolean
disabled?: boolean
onClick: () => boolean
}
const MenuBarButton = ({
label,
isActive = false,
disabled = false,
onClick
}: MenuBarButtonProps) => (
<button
onClick={onClick}
disabled={disabled}
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
>
{label}
</button>
)

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,206 +0,0 @@
import React, { useCallback } from 'react'
import { InputError } from './Error'
import { ImageUpload } from './ImageUpload'
import '../../styles/styles.css'
interface InputFieldProps {
label: string | React.ReactElement
description?: string
type?: 'text' | 'textarea'
placeholder: string
name: string
inputMode?: 'url'
value: string
error?: string
onChange: (name: string, value: string) => void
}
export const InputField = React.memo(
({
label,
description,
type = 'text',
placeholder,
name,
inputMode,
value,
error,
onChange
}: InputFieldProps) => {
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(name, e.target.value)
}
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>{label}</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
{type === 'textarea' ? (
<textarea
className='inputMain'
placeholder={placeholder}
name={name}
value={value}
onChange={handleChange}
></textarea>
) : (
<input
type={type}
className='inputMain'
placeholder={placeholder}
name={name}
inputMode={inputMode}
value={value}
onChange={handleChange}
/>
)}
{error && <InputError message={error} />}
</div>
)
}
)
interface CheckboxFieldProps {
label: string
name: string
isChecked: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type?: 'default' | 'stylized'
}
export const CheckboxField = React.memo(
({
label,
name,
isChecked,
handleChange,
type = 'default'
}: CheckboxFieldProps) => (
<div
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
}`}
>
<label htmlFor={name} className='form-label labelMain'>
{label}
</label>
<input
id={name}
type='checkbox'
className='CheckboxMain'
name={name}
checked={isChecked}
onChange={handleChange}
/>
</div>
)
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string | React.ReactElement
description?: string
error?: string
}
/**
* Uncontrolled input component with design classes, label, description and error support
*
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
* @param label
* @param description
* @param error
*
* @see {@link React.ComponentProps<'input'>}
*/
export const InputFieldUncontrolled = ({
label,
description,
error,
...rest
}: InputFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
{description && <p className='labelDescriptionMain'>{description}</p>}
<input className='inputMain' {...rest} />
{error && <InputError message={error} />}
</div>
)
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
}
export const CheckboxFieldUncontrolled = ({
label,
...rest
}: CheckboxFieldUncontrolledProps) => (
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
<label htmlFor={rest.id} className='form-label labelMain'>
{label}
</label>
<input type='checkbox' className='CheckboxMain' {...rest} />
</div>
)
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,42 +0,0 @@
import { Addressable } from 'types'
import { abbreviateNumber } from 'utils'
import { Zap } from './Zap'
import { Reactions } from './Reactions'
type InteractionsProps = {
addressable: Addressable
commentCount: number
}
export const Interactions = ({
addressable,
commentCount
}: InteractionsProps) => {
return (
<div className='IBMSMSplitMainBigSideSec'>
<div className='IBMSMSMBSS_Details'>
<a style={{ textDecoration: 'unset', color: 'unset' }}>
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
<div className='IBMSMSMBSS_Details_CardVisual'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSS_Details_CardVisualIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
</svg>
</div>
<p className='IBMSMSMBSS_Details_CardText'>
{abbreviateNumber(commentCount)}
</p>
</div>
</a>
<Zap addressable={addressable} />
<Reactions addressable={addressable} />
</div>
</div>
)
}

View File

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

View File

@ -1,12 +1,12 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useNavigation } from 'react-router-dom'
import styles from '../../styles/loadingSpinner.module.scss'
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}>
@ -16,62 +16,3 @@ export const LoadingSpinner = ({ desc }: Props) => {
</div>
)
}
export const RouterLoadingSpinner = () => {
const navigation = useNavigation()
if (navigation.state === 'idle') return null
const desc =
navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1)
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

@ -1,10 +0,0 @@
.formAction {
display: flex;
width: 100%;
justify-content: flex-end;
gap: var(--spacing-2);
}
.wrapper {
border-radius: 0;
}

View File

@ -1,148 +0,0 @@
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
codeBlockPlugin,
CodeToggle,
CreateLink,
diffSourcePlugin,
DiffSourceToggleWrapper,
directivesPlugin,
headingsPlugin,
imagePlugin,
InsertCodeBlock,
InsertImage,
InsertTable,
InsertThematicBreak,
linkDialogPlugin,
linkPlugin,
listsPlugin,
ListsToggle,
markdownShortcutPlugin,
MDXEditor,
MDXEditorMethods,
MDXEditorProps,
quotePlugin,
Separator,
StrikeThroughSupSubToggles,
tablePlugin,
thematicBreakPlugin,
toolbarPlugin,
UndoRedo
} from '@mdxeditor/editor'
import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor'
import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor'
import { YouTubeButton } from './YoutubeButton'
import '@mdxeditor/editor/style.css'
import '../../styles/mdxEditor.scss'
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef
} from 'react'
import { ImageDialog } from './ImageDialog'
import { LinkDialog } from './LinkDialog'
export interface EditorRef {
setMarkdown: (md: string) => void
}
interface EditorProps extends MDXEditorProps {}
/**
* The editor component is small wrapper (`forwardRef`) around {@link MDXEditor MDXEditor} that sets up the toolbars and plugins, and requires `markdown` and `onChange`.
* To reset editor markdown it's required to pass the {@link EditorRef EditorRef}.
*
* Extends {@link MDXEditorProps MDXEditorProps}
*
* **Important**: the markdown is not a state, but an _initialState_ and is not "controlled".
* All updates are handled with onChange and will not be reflected on markdown prop.
* This component should never re-render if used correctly.
* @see https://mdxeditor.dev/editor/docs/getting-started#basic-usage
*/
export const Editor = React.memo(
forwardRef<EditorRef, EditorProps>(({ markdown, onChange, ...rest }, ref) => {
const editorRef = useRef<MDXEditorMethods>(null)
const setMarkdown = useCallback((md: string) => {
editorRef.current?.setMarkdown(md)
}, [])
useImperativeHandle(ref, () => ({ setMarkdown }))
const plugins = useMemo(
() => [
toolbarPlugin({
toolbarContents: () => (
<DiffSourceToggleWrapper
children={
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<CreateLink />
<InsertImage />
<YouTubeButton />
<Separator />
<InsertTable />
<InsertThematicBreak />
<Separator />
<InsertCodeBlock />
</>
}
/>
)
}),
headingsPlugin(),
diffSourcePlugin({
viewMode: 'rich-text',
diffMarkdown: markdown
}),
quotePlugin(),
imagePlugin({
ImageDialog: ImageDialog
}),
tablePlugin(),
linkPlugin(),
linkDialogPlugin({
LinkDialog: LinkDialog
}),
listsPlugin(),
thematicBreakPlugin(),
directivesPlugin({
directiveDescriptors: [YoutubeDirectiveDescriptor]
}),
markdownShortcutPlugin(),
// HACK: due to a bug with shortcut interaction shortcut for code block is disabled
// Editor freezes if you type in ```word and put a space in between ``` word
codeBlockPlugin({
defaultCodeBlockLanguage: '',
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
return (
<MDXEditor
ref={editorRef}
contentEditableClassName='editor'
className='dark-theme dark-editor'
markdown={markdown}
plugins={plugins}
onChange={onChange}
{...rest}
/>
)
}),
() => true
)

View File

@ -1,166 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useCellValues, usePublisher } from '@mdxeditor/gurx'
import {
closeImageDialog$,
editorRootElementRef$,
imageDialogState$,
imageUploadHandler$,
saveImage$
} from '@mdxeditor/editor'
import styles from './Dialog.module.scss'
import { createPortal } from 'react-dom'
interface ImageFormFields {
src: string
title: string
altText: string
file: FileList
}
export const ImageDialog: React.FC = () => {
const [state, editorRootElementRef, imageUploadHandler] = useCellValues(
imageDialogState$,
editorRootElementRef$,
imageUploadHandler$
)
const saveImage = usePublisher(saveImage$)
const closeImageDialog = usePublisher(closeImageDialog$)
const { register, handleSubmit, setValue, reset } = useForm<ImageFormFields>({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
values: state.type === 'editing' ? (state.initialValues as any) : {}
})
const [open, setOpen] = useState(state.type !== 'inactive')
useEffect(() => {
setOpen(state.type !== 'inactive')
}, [state.type])
useEffect(() => {
if (!open) {
closeImageDialog()
reset({ src: '', title: '', altText: '' })
}
}, [closeImageDialog, open, reset])
const handleClose = useCallback(() => {
setOpen(false)
}, [])
if (!open) return null
if (!editorRootElementRef?.current) return null
return createPortal(
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Add an image</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'>
<form
className='pUMCB_ZapsInside'
onSubmit={(e) => {
void handleSubmit(saveImage)(e)
reset({ src: '', title: '', altText: '' })
e.preventDefault()
e.stopPropagation()
}}
>
{imageUploadHandler === null ? (
<input type='hidden' accept='image/*' {...register('file')} />
) : (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='file'>
Upload an image from your device:
</label>
<input type='file' accept='image/*' {...register('file')} />
</div>
)}
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='src'>
{imageUploadHandler !== null
? 'Or add an image from an URL:'
: 'Add an image from an URL:'}
</label>
<input
defaultValue={
state.type === 'editing'
? state.initialValues.src ?? ''
: ''
}
className='inputMain'
size={40}
autoFocus
{...register('src')}
onChange={(e) => setValue('src', e.currentTarget.value)}
placeholder={'Paste an image src'}
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='alt'>
Alt:
</label>
<input
type='text'
{...register('altText')}
className='inputMain'
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='title'>
Title:
</label>
<input
type='text'
{...register('title')}
className='inputMain'
/>
</div>
<div className={styles.formAction}>
<button
type='submit'
title={'Save'}
aria-label={'Save'}
className='btn btnMain btnMainPopup'
>
Save
</button>
<button
type='reset'
title={'Cancel'}
aria-label={'Cancel'}
className='btn btnMain btnMainPopup'
onClick={handleClose}
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>,
editorRootElementRef?.current
)
}

View File

@ -1,306 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import * as Popover from '@radix-ui/react-popover'
import * as Tooltip from '@radix-ui/react-tooltip'
import React from 'react'
import {
activeEditor$,
editorRootElementRef$,
iconComponentFor$,
cancelLinkEdit$,
linkDialogState$,
onWindowChange$,
removeLink$,
switchFromPreviewToLinkEdit$,
updateLink$,
ClickLinkCallback
} from '@mdxeditor/editor'
import { useForm } from 'react-hook-form'
import { Cell, useCellValues, usePublisher } from '@mdxeditor/gurx'
import styles from './Dialog.module.scss'
interface LinkEditFormProps {
url: string
title: string
onSubmit: (link: { url: string; title: string }) => void
onCancel: () => void
}
interface LinkFormFields {
url: string
title: string
}
export function LinkEditForm({
url,
title,
onSubmit,
onCancel
}: LinkEditFormProps) {
const { register, handleSubmit, setValue } = useForm<LinkFormFields>({
values: {
url,
title
}
})
return (
<div className='pUMCB_Zaps'>
<form
className='pUMCB_ZapsInside'
onSubmit={(e) => {
void handleSubmit(onSubmit)(e)
e.stopPropagation()
e.preventDefault()
}}
onReset={(e) => {
e.stopPropagation()
onCancel()
}}
>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='file'>
URL:
</label>
<input
defaultValue={url}
className='inputMain'
size={40}
autoFocus
{...register('url')}
onChange={(e) => setValue('url', e.currentTarget.value)}
placeholder={'Paste an URL'}
/>
</div>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain' htmlFor='link-title'>
Title:
</label>
<input
id='link-title'
className='inputMain'
size={40}
{...register('title')}
/>
</div>
<div className={styles.formAction}>
<button
type='submit'
title={'Set URL'}
aria-label={'Set URL'}
className='btn btnMain btnMainPopup'
>
Save
</button>
<button
type='reset'
title={'Cancel change'}
aria-label={'Cancel change'}
className='btn btnMain btnMainPopup'
>
Cancel
</button>
</div>
</form>
</div>
)
}
export const onClickLinkCallback$ = Cell<ClickLinkCallback | null>(null)
/** @internal */
export const LinkDialog = () => {
const [
editorRootElementRef,
activeEditor,
iconComponentFor,
linkDialogState,
onClickLinkCallback
] = useCellValues(
editorRootElementRef$,
activeEditor$,
iconComponentFor$,
linkDialogState$,
onClickLinkCallback$
)
const publishWindowChange = usePublisher(onWindowChange$)
const updateLink = usePublisher(updateLink$)
const cancelLinkEdit = usePublisher(cancelLinkEdit$)
const switchFromPreviewToLinkEdit = usePublisher(switchFromPreviewToLinkEdit$)
const removeLink = usePublisher(removeLink$)
React.useEffect(() => {
const update = () => {
activeEditor?.getEditorState().read(() => {
publishWindowChange(true)
})
}
window.addEventListener('resize', update)
window.addEventListener('scroll', update)
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update)
}
}, [activeEditor, publishWindowChange])
const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false)
const theRect = linkDialogState.rectangle
const urlIsExternal =
linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http')
return (
<Popover.Root open={linkDialogState.type !== 'inactive'}>
<Popover.Anchor
data-visible={linkDialogState.type === 'edit'}
style={{
position: 'fixed',
top: `${theRect?.top ?? 0}px`,
left: `${theRect?.left ?? 0}px`,
width: `${theRect?.width ?? 0}px`,
height: `${theRect?.height ?? 0}px`
}}
/>
<Popover.Portal container={editorRootElementRef?.current}>
<Popover.Content
sideOffset={5}
onOpenAutoFocus={(e) => {
e.preventDefault()
}}
key={linkDialogState.linkNodeKey}
className={[
'popUpMainCard',
...(linkDialogState.type === 'edit' ? [styles.wrapper] : [])
].join(' ')}
>
{linkDialogState.type === 'edit' && (
<LinkEditForm
url={linkDialogState.url}
title={linkDialogState.title}
onSubmit={updateLink}
onCancel={cancelLinkEdit.bind(null)}
/>
)}
{linkDialogState.type === 'preview' && (
<>
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
<p className='IBMSMSMSSS_Author_Top_Address'>
<a
className={styles.linkDialogPreviewAnchor}
href={linkDialogState.url}
{...(urlIsExternal
? { target: '_blank', rel: 'noreferrer' }
: {})}
onClick={(e) => {
if (
onClickLinkCallback !== null &&
typeof onClickLinkCallback === 'function'
) {
e.preventDefault()
onClickLinkCallback(linkDialogState.url)
}
}}
title={
urlIsExternal
? `Open ${linkDialogState.url} in new window`
: linkDialogState.url
}
>
<span>{linkDialogState.url}</span>
{urlIsExternal && iconComponentFor('open_in_new')}
</a>
</p>
</div>
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
switchFromPreviewToLinkEdit()
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
</svg>
</div>
<Tooltip.Provider>
<Tooltip.Root open={copyUrlTooltipOpen}>
<Tooltip.Trigger asChild>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
void window.navigator.clipboard
.writeText(linkDialogState.url)
.then(() => {
setCopyUrlTooltipOpen(true)
setTimeout(() => {
setCopyUrlTooltipOpen(false)
}, 1000)
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
</svg>
</div>
</Tooltip.Trigger>
<Tooltip.Portal container={editorRootElementRef?.current}>
<Tooltip.Content sideOffset={5}>
{'Copied!'}
<Tooltip.Arrow />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<div
className='IBMSMSMSSS_Author_Top_IconWrapped'
onClick={() => {
removeLink()
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 640 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMSSS_Author_Top_Icon'
>
<path d='M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z' />
</svg>
</div>
</div>
</div>
</>
)}
<Popover.Arrow />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}

View File

@ -1,65 +0,0 @@
import {
CodeBlockEditorDescriptor,
useCodeBlockEditorContext
} from '@mdxeditor/editor'
import { useCallback, useEffect, useRef } from 'react'
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
match: (_language, _meta) => true,
priority: 0,
Editor: ({ code, focusEmitter }) => {
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
const defaultValue = useRef(code)
const codeRef = useRef<HTMLElement>(null)
const handleInput = useCallback(
(e: React.FormEvent<HTMLElement>) => {
setCode(e.currentTarget.innerHTML)
},
[setCode]
)
useEffect(() => {
const handleFocus = () => {
if (codeRef.current) {
codeRef.current.focus()
}
}
focusEmitter.subscribe(handleFocus)
}, [focusEmitter])
useEffect(() => {
const currentRef = codeRef.current
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Backspace' || event.key === 'Delete') {
if (codeRef.current?.textContent === '') {
parentEditor.update(() => {
lexicalNode.selectNext()
lexicalNode.remove()
})
}
}
}
if (currentRef) {
currentRef.addEventListener('keydown', handleKeyDown)
}
return () => {
if (currentRef) {
currentRef.removeEventListener('keydown', handleKeyDown)
}
}
}, [lexicalNode, parentEditor])
return (
<pre>
<code
ref={codeRef}
contentEditable={true}
onInput={handleInput}
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
/>
</pre>
)
}
}

View File

@ -1,37 +0,0 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { createDirectives, presetDirectiveConfigs } from 'marked-directive'
import { youtubeDirective } from './YoutubeDirective'
import { useMemo } from 'react'
interface ViewerProps {
markdown: string
}
export const Viewer = ({ markdown }: ViewerProps) => {
const html = useMemo(() => {
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
if (node.nodeName && node.nodeName === 'IFRAME') {
const src = node.attributes.getNamedItem('src')
if (!(src && src.value.startsWith('https://www.youtube.com/embed/'))) {
node.remove()
}
}
})
return DOMPurify.sanitize(
marked
.use(createDirectives([...presetDirectiveConfigs, youtubeDirective]))
.parse(`${markdown}`, {
async: false
}),
{
ADD_TAGS: ['iframe']
}
)
}, [markdown])
return (
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
)
}

View File

@ -1,36 +0,0 @@
import { LeafDirective } from 'mdast-util-directive'
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
function getId(url: string) {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
const match = url.match(regExp)
return match && match[7].length == 11 ? match[7] : false
}
export const YouTubeButton = () => {
const insertDirective = usePublisher(insertDirective$)
return (
<DialogButton
tooltipTitle='Insert Youtube video'
submitButtonTitle='Insert video'
dialogInputPlaceholder='Paste the youtube video URL'
buttonContent='YT'
onSubmit={(url) => {
const videoId = getId(url)
if (videoId) {
insertDirective({
name: 'youtube',
type: 'leafDirective',
attributes: { id: videoId },
children: []
} as LeafDirective)
} else {
alert('Invalid YouTube URL')
}
}}
/>
)
}

View File

@ -1,37 +0,0 @@
import { type DirectiveConfig } from 'marked-directive'
// defines `:youtube` directive
export const youtubeDirective: DirectiveConfig = {
level: 'block',
marker: '::',
renderer(token) {
//https://www.youtube.com/embed/<VIDEO_ID>
//::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('#', '')
}
}
}
}
if (vid) {
return `<iframe title="Video embed" width="560" height="315" src="https://www.youtube.com/embed/${vid}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
}
return false
}
}

View File

@ -1,59 +0,0 @@
import { LeafDirective } from 'mdast-util-directive'
import { DirectiveDescriptor } from '@mdxeditor/editor'
interface YoutubeDirectiveNode extends LeafDirective {
name: 'youtube'
attributes: { id: string }
}
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
{
name: 'youtube',
type: 'leafDirective',
testNode(node) {
return node.name === 'youtube'
},
attributes: ['id'],
hasChildren: false,
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<button
type='button'
title='delete'
className='btnMain'
onClick={() => {
parentEditor.update(() => {
lexicalNode.selectNext()
lexicalNode.remove()
})
}}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 448 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z' />
</svg>
</button>
<iframe
width='560'
height='315'
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
title='YouTube video player'
frameBorder='0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
/>
</div>
)
}
}

View File

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

View File

@ -1,4 +1,5 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
Fragment,
useCallback,
@ -7,89 +8,73 @@ import React, {
useRef,
useState
} from 'react'
import {
useActionData,
useLoaderData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { FixedSizeList } from 'react-window'
import { useGames } from '../hooks'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { FixedSizeList as List } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../constants'
import { RelayController } from '../controllers'
import { useAppSelector, useGames } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import '../styles/styles.css'
import {
DownloadUrl,
ModFormState,
ModPageLoaderResult,
ModPermissions,
MODPERMISSIONS_CONF,
MODPERMISSIONS_DESC,
SubmitModActionResult
} from '../types'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import {
initializeFormState,
isReachable,
isValidImageUrl,
isValidUrl,
log,
LogType,
MOD_DRAFT_CACHE_KEY
now
} from '../utils'
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
import { OriginalAuthor } from './OriginalAuthor'
import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor'
import { 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 { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner'
interface FormErrors {
game?: string
title?: string
body?: string
featuredImageUrl?: string
summary?: string
nsfw?: string
screenshotsUrls?: string[]
tags?: string
downloadUrls?: string[]
}
interface GameOption {
value: string
label: string
}
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 navigation = useNavigation()
const submit = useSubmit()
type ModFormProps = {
existingModData?: ModDetails
}
export const ModForm = ({ existingModData }: ModFormProps) => {
const location = useLocation()
const navigate = useNavigate()
const games = useGames()
const userState = useAppSelector((state) => state.user)
const [isPublishing, setIsPublishing] = useState(false)
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()
)
// 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])
const [formErrors, setFormErrors] = useState<FormErrors>({})
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)
if (location.pathname === appRoutes.submitMod) {
setFormState(initializeFormState())
}
}, [formState, isEditing, setCache])
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
const editorRef = useRef<EditorRef>(null)
useEffect(() => {
if (existingModData) {
setFormState(initializeFormState(existingModData))
}
}, [existingModData])
useEffect(() => {
const options = games.map((game) => ({
@ -117,13 +102,6 @@ export const ModForm = () => {
[]
)
const handleRadioChange = useCallback((name: string, value: boolean) => {
setFormState((prevState) => ({
...prevState,
[name]: value
}))
}, [])
const addScreenshotUrl = useCallback(() => {
setFormState((prevState) => ({
...prevState,
@ -191,85 +169,185 @@ 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
}))
const handlePublish = async () => {
setIsPublishing(true)
let hexPubkey: string
if (userState.auth && userState.user?.pubkey) {
hexPubkey = userState.user.pubkey as string
} else {
hexPubkey = (await window.nostr?.getPublicKey()) as string
}
}, [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'
if (!hexPubkey) {
toast.error('Could not get pubkey')
return
}
if (!(await validateState())) {
setIsPublishing(false)
return
}
const uuid = uuidv4()
const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: formState.body,
tags: [
['d', formState.dTag || uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
existingModData
? existingModData.published_at.toString()
: currentTimeStamp.toString()
],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
}
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
})
},
[formState, isEditing, submit]
)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = useCallback(() => {
setShowConfirmPopup(true)
}, [])
const handleResetConfirm = useCallback(
(confirm: boolean) => {
setShowConfirmPopup(false)
if (!signedEvent) {
setIsPublishing(false)
return
}
// Cancel if not confirmed
if (!confirm) return
const publishedOnRelays = await RelayController.getInstance().publish(
signedEvent as Event
)
// Reset fields to the initial or original existing data
const initialState = initializeFormState(mod)
// Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
// Reset editor
editorRef.current?.setMarkdown(initialState.body)
setFormState(initialState)
// 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'
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
},
[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 = ''
navigate(getModPageRoute(naddr))
}
setIsPublishing(false)
}
const validateState = async (): Promise<boolean> => {
const errors: FormErrors = {}
if (formState.game === '') {
errors.game = 'Game field can not be empty'
}
if (formState.title === '') {
errors.title = 'Title field can not be empty'
}
if (formState.body === '') {
errors.body = 'Body field can not be empty'
}
if (formState.featuredImageUrl === '') {
errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty'
} else if (
!isValidImageUrl(formState.featuredImageUrl) ||
!(await isReachable(formState.featuredImageUrl))
) {
errors.featuredImageUrl =
'FeaturedImageUrl must be a valid and reachable image URL'
}
if (formState.summary === '') {
errors.summary = 'Summary field can not be empty'
}
if (formState.screenshotsUrls.length === 0) {
errors.screenshotsUrls = ['Required at least one screenshot url']
} else {
for (let i = 0; i < formState.screenshotsUrls.length; i++) {
const url = formState.screenshotsUrls[i]
if (
!isValidUrl(url) ||
!isValidImageUrl(url) ||
!(await isReachable(url))
) {
if (!errors.screenshotsUrls)
errors.screenshotsUrls = Array(formState.screenshotsUrls.length)
errors.screenshotsUrls![i] =
'All screenshot URLs must be valid and reachable image URLs'
}
}
}
if (formState.tags === '') {
errors.tags = 'Tags field can not be empty'
}
if (formState.downloadUrls.length === 0) {
errors.downloadUrls = ['Required at least one download url']
} else {
for (let i = 0; i < formState.downloadUrls.length; i++) {
const downloadUrl = formState.downloadUrls[i]
if (!isValidUrl(downloadUrl.url)) {
if (!errors.downloadUrls)
errors.downloadUrls = Array(formState.downloadUrls.length)
errors.downloadUrls![i] = 'Download url must be valid and reachable'
}
}
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
return (
<form className='IBMSMSMBS_Write' onSubmit={handlePublish}>
<>
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />}
<GameDropdown
options={gameOptions}
selected={formState?.game}
error={formErrors?.game}
selected={formState.game}
error={formErrors.game}
onChange={handleInputChange}
/>
@ -278,46 +356,30 @@ export const ModForm = () => {
placeholder='Return the banana mod'
name='title'
value={formState.title}
error={formErrors?.title}
error={formErrors.title}
onChange={handleInputChange}
/>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Body</label>
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={body}
placeholder="Here's what this mod is all about"
onChange={(md) => {
handleInputChange('body', md)
}}
onError={(payload) => {
toast.error('Markdown error. Fix manually in the source mode.')
log(true, LogType.Error, payload.error)
}}
/>
</div>
{typeof formErrors?.body !== 'undefined' && (
<InputError message={formErrors?.body} />
)}
<input
name='body'
hidden
value={encodeURIComponent(formState?.body)}
readOnly
/>
</div>
<InputField
label='Body'
type='richtext'
placeholder="Here's what this mod is all about"
name='body'
value={formState.body}
error={formErrors.body}
onChange={handleInputChange}
/>
<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}
error={formErrors.featuredImageUrl}
onChange={handleInputChange}
/>
<InputField
label='Summary'
@ -325,7 +387,7 @@ export const ModForm = () => {
placeholder='This is a quick description of my mod'
name='summary'
value={formState.summary}
error={formErrors?.summary}
error={formErrors.summary}
onChange={handleInputChange}
/>
<CheckboxField
@ -333,33 +395,7 @@ export const ModForm = () => {
name='nsfw'
isChecked={formState.nsfw}
handleChange={handleCheckboxChange}
type='stylized'
/>
<CheckboxField
label='This is a repost of a mod I did not create'
name='repost'
isChecked={formState.repost}
handleChange={handleCheckboxChange}
type='stylized'
/>
{formState.repost && (
<>
<InputField
label={
<span>
Created by:{' '}
{<OriginalAuthor value={formState.originalAuthor || ''} />}
</span>
}
type='text'
placeholder="Original author's name, npub or nprofile"
name='originalAuthor'
value={formState.originalAuthor || ''}
error={formErrors?.originalAuthor}
onChange={handleInputChange}
/>
</>
)}
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Screenshots URLs</label>
@ -367,7 +403,6 @@ export const ModForm = () => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addScreenshotUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -381,24 +416,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
@ -407,16 +426,16 @@ export const ModForm = () => {
onUrlChange={handleScreenshotUrlChange}
onRemove={removeScreenshotUrl}
/>
{formErrors?.screenshotsUrls &&
formErrors?.screenshotsUrls[index] && (
<InputError message={formErrors?.screenshotsUrls[index]} />
{formErrors.screenshotsUrls &&
formErrors.screenshotsUrls[index] && (
<InputError message={formErrors.screenshotsUrls[index]} />
)}
</Fragment>
))}
{formState.screenshotsUrls.length === 0 &&
formErrors?.screenshotsUrls &&
formErrors?.screenshotsUrls[0] && (
<InputError message={formErrors?.screenshotsUrls[0]} />
formErrors.screenshotsUrls &&
formErrors.screenshotsUrls[0] && (
<InputError message={formErrors.screenshotsUrls[0]} />
)}
</div>
<InputField
@ -425,14 +444,9 @@ export const ModForm = () => {
placeholder='Tags'
name='tags'
value={formState.tags}
error={formErrors?.tags}
error={formErrors.tags}
onChange={handleInputChange}
/>
<CategoryAutocomplete
game={formState.game}
LTags={formState.LTags}
setFormState={setFormState}
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Download URLs</label>
@ -440,7 +454,6 @@ export const ModForm = () => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addDownloadUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -455,230 +468,56 @@ 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}
/>
{formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
<InputError message={formErrors?.downloadUrls[index]} />
{formErrors.downloadUrls && formErrors.downloadUrls[index] && (
<InputError message={formErrors.downloadUrls[index]} />
)}
</Fragment>
))}
{formState.downloadUrls.length === 0 &&
formErrors?.downloadUrls &&
formErrors?.downloadUrls[0] && (
<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>
{formState.downloadUrls.length === 0 &&
formErrors.downloadUrls &&
formErrors.downloadUrls[0] && (
<InputError message={formErrors.downloadUrls[0]} />
)}
</div>
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
type='button'
onClick={handleReset}
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
onClick={handlePublish}
disabled={isPublishing}
>
{isEditing ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
type='submit'
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
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
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
/>
)}
</form>
</>
)
}
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
}
@ -687,13 +526,11 @@ const DownloadUrlFields = React.memo(
({
index,
url,
title,
hash,
signatureKey,
malwareScanLink,
modVersion,
customNote,
mediaUrl,
onUrlChange,
onRemove
}: DownloadUrlFieldsProps) => {
@ -718,7 +555,6 @@ const DownloadUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -731,28 +567,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
@ -863,43 +677,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>
)
}
@ -924,7 +701,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}
/>
@ -932,7 +709,6 @@ const ScreenshotUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -987,9 +763,8 @@ const GameDropdown = ({
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Game</label>
<p className='labelDescriptionMain'>
Can't find the game you're looking for? You can temporarily publish the
mod under '(Unlisted Game)' and later edit it with the proper game name
once we add it.
Can't find the game you're looking for? You can temporarily publish the mod under '(Unlisted Game)' and
later edit it with the proper game name once we add it.
</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
@ -1013,7 +788,6 @@ const GameDropdown = ({
className='btn btnMain btnMainInsideField btnMainRemove'
type='button'
onClick={() => onChange('game', '')}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -1026,7 +800,7 @@ const GameDropdown = ({
</svg>
</button>
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
<FixedSizeList
<List
height={500}
width={'100%'}
itemCount={filteredOptions.length}
@ -1048,15 +822,13 @@ const GameDropdown = ({
{filteredOptions[index].label}
</div>
)}
</FixedSizeList>
</List>
</div>
</div>
</div>
</div>
{error && <InputError message={error} />}
<p className='labelDescriptionMain'>
Note: Please mention the game name in the body text of your mod post
(e.g., 'This is a mod for Game Name') so we know what to look for and
add.
<p className='labelDescriptionMain'>Note: Please mention the game name in the body text of your mod post (e.g., 'This is a mod for Game Name')
so we know what to look for and add.
</p>
</div>
)

View File

@ -0,0 +1,155 @@
import { useAppSelector } from 'hooks'
import React from 'react'
import { Dispatch, SetStateAction } from 'react'
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types'
type Props = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
}
export const ModFilter = React.memo(
({ filterOptions, setFilterOptions }: Props) => {
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.sort}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SortBy).map((item, index) => (
<div
key={`sortByItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
sort: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.moderated}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(ModeratedFilter).map((item, index) => {
if (item === ModeratedFilter.Unmoderated_Fully) {
const isAdmin =
userState.user?.npub ===
import.meta.env.VITE_REPORTING_NPUB
if (!isAdmin) return null
}
return (
<div
key={`moderatedFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
moderated: item
}))
}
>
{item}
</div>
)
})}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.nsfw}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(NSFWFilter).map((item, index) => (
<div
key={`nsfwFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</div>
))}
</div>
</div>
</div>
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
{filterOptions.source === window.location.host
? `Show From: ${filterOptions.source}`
: 'Show All'}
</button>
<div className='dropdown-menu dropdownMainMenu'>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: window.location.host
}))
}
>
Show From: {window.location.host}
</div>
<div
className='dropdown-item dropdownMainMenuItem'
onClick={() =>
setFilterOptions((prev) => ({
...prev,
source: 'Show All'
}))
}
>
Show All
</div>
</div>
</div>
</div>
</div>
</div>
)
}
)

View File

@ -1,39 +0,0 @@
import { AlertPopupProps } from 'types'
import { AlertPopup } from './AlertPopup'
import { useLocalStorage } from 'hooks'
type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'>
/**
* Triggers when the user wants to switch the filter to see any of the NSFW options
* (including preferences)
*
* Option will be remembered for the session only and will not show the popup again
*/
export const NsfwAlertPopup = ({
handleConfirm,
handleClose
}: NsfwAlertPopup) => {
const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>(
'confirm-nsfw',
false
)
return (
!confirmNsfw && (
<AlertPopup
header='Confirm'
label='Are you above 18 years of age?'
handleClose={() => {
handleConfirm(false)
handleClose()
}}
handleConfirm={(confirm: boolean) => {
setConfirmNsfw(confirm)
handleConfirm(confirm)
handleClose()
}}
/>
)
)
}

View File

@ -1,48 +0,0 @@
import { nip19 } from 'nostr-tools'
import { appRoutes, getProfilePageRoute } from 'routes'
import { npubToHex } from 'utils'
import { ProfileLink } from './ProfileLink'
interface OriginalAuthorProps {
value: string
fallback?: boolean
}
export const OriginalAuthor = ({
value,
fallback = false
}: OriginalAuthorProps) => {
let profilePubkey
let displayName = '[name not set up]'
// Try to decode/encode depending on what we send to link
let profileRoute = appRoutes.home
try {
if (value.startsWith('nprofile1')) {
const decoded = nip19.decode(value as `nprofile1${string}`)
profileRoute = getProfilePageRoute(value)
profilePubkey = decoded?.data.pubkey
} else if (value.startsWith('npub1')) {
profilePubkey = npubToHex(value)
const nprofile = profilePubkey
? nip19.nprofileEncode({
pubkey: profilePubkey
})
: undefined
if (nprofile) {
profileRoute = getProfilePageRoute(nprofile)
}
} else {
displayName = value
}
} catch (error) {
console.error('Failed to create profile link:', error)
displayName = value
}
if (profileRoute && profilePubkey)
return <ProfileLink pubkey={profilePubkey} profileRoute={profileRoute} />
return fallback ? displayName : null
}

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,15 +0,0 @@
import { useProfile } from 'hooks/useProfile'
import { Link } from 'react-router-dom'
interface ProfileLinkProps {
pubkey: string
profileRoute: string
}
export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => {
const profile = useProfile(pubkey)
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
return <Link to={profileRoute}>{displayName}</Link>
}

View File

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

View File

@ -1,93 +0,0 @@
import { useFetcher } from 'react-router-dom'
import { CheckboxFieldUncontrolled } from 'components/Inputs'
import { useEffect } from 'react'
import { ReportReason } from 'types/report'
import { LoadingSpinner } from './LoadingSpinner'
import { PopupProps } from 'types'
type ReportPopupProps = {
openedAt: number
reasons: ReportReason[]
} & PopupProps
export const ReportPopup = ({
openedAt,
reasons,
handleClose
}: ReportPopupProps) => {
// Use openedAt to allow for multiple reports
// by default, fetcher will remember the data
const fetcher = useFetcher({ key: openedAt.toString() })
// Close automatically if action succeeds
useEffect(() => {
if (fetcher.data) {
const { isSent } = fetcher.data
if (isSent) {
handleClose()
}
}
}, [fetcher, handleClose])
return (
<>
{fetcher.state !== 'idle' && <LoadingSpinner desc={''} />}
<div className='popUpMain'>
<div className='ContainerMain'>
<div className='popUpMainCardWrapper'>
<div className='popUpMainCard popUpMainCardQR'>
<div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'>
<h3>Report Post</h3>
</div>
<div className='popUpMainCardTopClose' onClick={handleClose}>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
style={{ zIndex: 1 }}
>
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
</svg>
</div>
</div>
<div className='pUMCB_Zaps'>
<fetcher.Form
className='pUMCB_ZapsInside'
method='post'
action='report'
>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Why are you reporting this?
</label>
{reasons.map((r) => (
<CheckboxFieldUncontrolled
key={r.key}
label={r.label}
name={r.key}
defaultChecked={false}
/>
))}
</div>
<button
className='btn btnMain pUMCB_Report'
type='submit'
style={{ width: '100%' }}
>
Submit Report
</button>
</fetcher.Form>
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

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

View File

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

View File

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

View File

@ -1,8 +1,6 @@
import { getRelayListForUser } from '@nostr-dev-kit/ndk'
import { QRCodeSVG } from 'qrcode.react'
import React, {
Dispatch,
PropsWithChildren,
ReactNode,
SetStateAction,
useCallback,
@ -11,7 +9,7 @@ import React, {
} from 'react'
import Countdown, { CountdownRenderProps } from 'react-countdown'
import { toast } from 'react-toastify'
import { ZapController } from '../controllers'
import { MetadataController, ZapController } from '../controllers'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
import '../styles/popup.css'
import { PaymentRequest, UserProfile } from '../types'
@ -20,9 +18,6 @@ import {
formatNumber,
getTagValue,
getZapAmount,
log,
LogType,
timeout,
unformatNumber
} from '../utils'
import { LoadingSpinner } from './LoadingSpinner'
@ -128,7 +123,6 @@ type ZapQRProps = {
handleQRExpiry: () => void
setTotalZapAmount?: Dispatch<SetStateAction<number>>
setHasZapped?: Dispatch<SetStateAction<boolean>>
profileImage?: string
}
export const ZapQR = React.memo(
@ -137,10 +131,8 @@ export const ZapQR = React.memo(
handleClose,
handleQRExpiry,
setTotalZapAmount,
setHasZapped,
profileImage,
children
}: PropsWithChildren<ZapQRProps>) => {
setHasZapped
}: ZapQRProps) => {
const { ndk } = useNDKContext()
useDidMount(() => {
@ -180,10 +172,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}
@ -191,21 +180,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={() => {
@ -217,7 +191,6 @@ export const ZapQR = React.memo(
{paymentRequest.pr}
</label>
<Timer onTimerExpired={handleQRExpiry} />
{children}
</div>
)
}
@ -278,13 +251,13 @@ export const ZapPopUp = ({
setHasZapped,
handleClose
}: ZapPopUpProps) => {
const { ndk, findMetadata } = useNDKContext()
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
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>) => {
@ -294,7 +267,7 @@ export const ZapPopUp = ({
const generatePaymentRequest =
useCallback(async (): Promise<PaymentRequest | null> => {
let userHexKey: string | undefined
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -302,11 +275,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) {
@ -315,7 +284,7 @@ export const ZapPopUp = ({
return null
}
setLoadingSpinnerDesc('Finding receiver metadata')
setLoadingSpinnerDesc('finding receiver metadata')
const receiverMetadata = await findMetadata(receiver)
@ -327,29 +296,10 @@ 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)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
const zapController = ZapController.getInstance()
setLoadingSpinnerDesc('Creating zap request')
@ -358,7 +308,6 @@ export const ZapPopUp = ({
receiverMetadata.lud16,
amount,
receiverMetadata.pubkey as string,
receiverRelays,
userHexKey,
message,
eventId,
@ -371,7 +320,7 @@ export const ZapPopUp = ({
.finally(() => {
setIsLoading(false)
})
}, [amount, message, userState, receiver, eventId, aTag, ndk, findMetadata])
}, [amount, message, userState, receiver, eventId, aTag])
const handleGenerateQRCode = async () => {
const pr = await generatePaymentRequest()
@ -503,7 +452,6 @@ export const ZapPopUp = ({
handleQRExpiry={handleQRExpiry}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
profileImage={receiverMetadata?.image}
/>
)}
{lastNode}
@ -534,7 +482,7 @@ export const ZapSplit = ({
setHasZapped,
handleClose
}: ZapSplitProps) => {
const { ndk, findMetadata } = useNDKContext()
const { findMetadata } = useNDKContext()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [amount, setAmount] = useState<number>(0)
@ -554,8 +502,8 @@ export const ZapSplit = ({
setAuthor(res)
})
const adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
findMetadata(adminNpubs[0]).then((res) => {
const metadataController = await MetadataController.getInstance()
findMetadata(metadataController.adminNpubs[0]).then((res) => {
setAdmin(res)
})
})
@ -584,7 +532,7 @@ export const ZapSplit = ({
const generatePaymentInvoices = async () => {
if (!amount) return null
let userHexKey: string | undefined
let userHexKey: string
setIsLoading(true)
setLoadingSpinnerDesc('Getting user pubkey')
@ -592,11 +540,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) {
@ -613,30 +557,12 @@ export const ZapSplit = ({
const invoices = new Map<string, PaymentRequest>()
if (authorShare > 0 && author?.pubkey && author?.lud16) {
// Find the receiver's read relays.
const authorRelays = await getRelayListForUser(
author.pubkey as string,
ndk
)
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
setLoadingSpinnerDesc('Generating invoice for author')
const invoice = await zapController
.getLightningPaymentRequest(
author.lud16,
authorShare,
author.pubkey as string,
authorRelays,
userHexKey,
message,
eventId,
@ -653,31 +579,12 @@ 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)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList.readRelayUrls
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in getting zap receiver's read relays`,
err
)
return [] as string[]
})
setLoadingSpinnerDesc('Generating invoice for site owner')
const invoice = await zapController
.getLightningPaymentRequest(
admin.lud16,
adminShare,
admin.pubkey as string,
adminRelays,
userHexKey,
message,
eventId,
@ -759,8 +666,6 @@ export const ZapSplit = ({
toast.warn('Webln is not present. Use QR code to send zap.')
setInvoices(paymentInvoices)
}
setIsLoading(false)
}
const removeInvoice = (key: string) => {
@ -775,56 +680,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
@ -834,10 +689,7 @@ export const ZapSplit = ({
handleQRExpiry={() => removeInvoice('author')}
setTotalZapAmount={setTotalZapAmount}
setHasZapped={setHasZapped}
profileImage={author?.image}
>
{feedback(true)}
</ZapQR>
/>
)
}
@ -852,10 +704,7 @@ export const ZapSplit = ({
handleClose()
}}
handleQRExpiry={() => removeInvoice('admin')}
profileImage={admin?.image}
>
{feedback(false)}
</ZapQR>
/>
)
}

View File

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

View File

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

View File

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

View File

@ -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,131 +0,0 @@
import { Dots } from 'components/Spinner'
import { useNDKContext } from 'hooks'
import { useComments } from 'hooks/useComments'
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
import { useLoaderData } from 'react-router-dom'
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'
type Props = {
addressable: Addressable
setCommentCount: Dispatch<SetStateAction<number>>
}
export const Comments = ({ addressable, setCommentCount }: Props) => {
const { ndk } = useNDKContext()
const { commentEvents, setCommentEvents } = useComments(
addressable.author,
addressable.aTag
)
const { event } = useLoaderData() as
| ModPageLoaderResult
| BlogPageLoaderResult
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
sort: SortByEnum.Latest,
author: AuthorFilterEnum.All_Comments
})
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Initial loading to indicate comments fetching (stop after 5 seconds)
const t = window.setTimeout(() => setIsLoading(false), 5000)
return () => {
window.clearTimeout(t)
}
}, [])
useEffect(() => {
setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount])
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
const handleSubmit = handleCommentSubmit(
event,
setCommentEvents,
setVisible,
ndk
)
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const comments = useMemo(() => {
let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter(
(comment) => comment.event.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
)
} 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
)
}
return filteredComments
}, [visible, filterOptions.author, filterOptions.sort, addressable.author])
const discoveredCount = commentEvents.length - visible.length
return (
<div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'>
{/* 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>
</div>
<Filter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
<div className='IBMSMSMBSSCommentsList'>
{comments.map((comment) => (
<Comment key={comment.event.id} comment={comment} />
))}
</div>
</div>
</div>
)
}

View File

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

View File

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

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,3 @@
export * from './metadata'
export * from './relay'
export * from './zap'
export * from './image'

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -1,15 +0,0 @@
import { useState } from 'react'
import { useNDKContext } from './useNDKContext'
import { useDidMount } from './useDidMount'
import { CurationSetIdentifiers, getReportingSet } from 'utils'
export const useCuratedSet = (type: CurationSetIdentifiers) => {
const ndkContext = useNDKContext()
const [curatedSet, setCuratedSet] = useState<string[]>([])
useDidMount(async () => {
setCuratedSet(await getReportingSet(type, ndkContext))
})
return curatedSet
}

View File

@ -6,13 +6,8 @@ import {
ModeratedFilter,
MuteLists,
NSFWFilter,
RepostFilter,
SortBy,
WOTFilterOptions
SortBy
} from 'types'
import { npubToHex } from 'utils'
import { useAppSelector } from './redux'
import { isInWoT } from 'utils/wot'
export const useFilteredMods = (
mods: ModDetails[],
@ -22,25 +17,10 @@ export const useFilteredMods = (
muteLists: {
admin: MuteLists
user: MuteLists
},
repostList: string[],
author?: string | undefined
}
) => {
const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector(
(state) => state.wot
)
return useMemo(() => {
const nsfwFilter = (mods: ModDetails[]) => {
// Add nsfw tag to mods included in nsfwList
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
mods = mods.map((mod) => {
return !mod.nsfw && nsfwList.includes(mod.aTag)
? { ...mod, nsfw: true }
: mod
})
}
// Determine the filtering logic based on the NSFW filter option
switch (filterOptions.nsfw) {
case NSFWFilter.Hide_NSFW:
@ -55,92 +35,14 @@ export const useFilteredMods = (
}
}
const repostFilter = (mods: ModDetails[]) => {
if (filterOptions.repost !== RepostFilter.Hide_Repost) {
// Add repost tag to mods included in repostList
mods = mods.map((mod) => {
return !mod.repost && repostList.includes(mod.aTag)
? { ...mod, repost: true }
: mod
})
}
// Determine the filtering logic based on the Repost filter option
switch (filterOptions.repost) {
case RepostFilter.Hide_Repost:
return mods.filter(
(mod) => !mod.repost && !repostList.includes(mod.aTag)
)
case RepostFilter.Show_Repost:
return mods
case RepostFilter.Only_Repost:
return mods.filter(
(mod) => mod.repost || repostList.includes(mod.aTag)
)
}
}
const wotFilter = (mods: ModDetails[]) => {
// Determine the filtering logic based on the WOT filter option and user state
// when user is not logged in use Site_Only
if (!userState.auth) {
return mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
}
// when user is logged, allow other filter selections
const isWoTNpub =
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
switch (filterOptions.wot) {
case WOTFilterOptions.None:
// Only admins can choose None, use siteWoT for others
return isWoTNpub
? mods
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
case WOTFilterOptions.Exclude:
// Only admins can choose Exlude, use siteWot for others
// Exlude returns the mods not in the site's WoT
return isWoTNpub
? mods.filter((mod) => !isInWoT(siteWot, siteWotLevel, mod.author))
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
case WOTFilterOptions.Site_Only:
return mods.filter((mod) =>
isInWoT(siteWot, siteWotLevel, mod.author)
)
case WOTFilterOptions.Mine_Only:
// Only admins can choose Mine_Only, use siteWoT for others
return isWoTNpub
? mods.filter((mod) => isInWoT(userWot, userWotLevel, mod.author))
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
case WOTFilterOptions.Site_And_Mine:
return mods.filter(
(mod) =>
isInWoT(siteWot, siteWotLevel, mod.author) &&
isInWoT(userWot, userWotLevel, mod.author)
)
}
}
let filtered = nsfwFilter(mods)
filtered = repostFilter(filtered)
filtered = wotFilter(filtered)
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.npub &&
npubToHex(userState.user.npub as string) === author
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
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"
if (!(isAdmin && isUnmoderatedFully)) {
filtered = filtered.filter(
(mod) =>
!muteLists.admin.authors.includes(mod.author) &&
@ -164,21 +66,12 @@ export const useFilteredMods = (
return filtered
}, [
userState.auth,
userState.user?.npub,
filterOptions.sort,
filterOptions.moderated,
filterOptions.wot,
filterOptions.nsfw,
filterOptions.repost,
author,
mods,
muteLists,
nsfwList,
repostList,
siteWot,
siteWotLevel,
userWot,
userWotLevel
nsfwList
])
}

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,64 +0,0 @@
import React, { useMemo } from 'react'
import {
getLocalStorageItem,
mergeWithInitialValue,
removeLocalStorageItem,
setLocalStorageItem
} from 'utils'
const useLocalStorageSubscribe = (callback: () => void) => {
window.addEventListener('storage', callback)
return () => window.removeEventListener('storage', callback)
}
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const getSnapshot = () => {
// Get the stored value
const storedValue = getLocalStorageItem(key, initialValue)
// Parse the value
const parsedStoredValue = JSON.parse(storedValue)
// Merge the default and the stored in case some of the required fields are missing
return JSON.stringify(
mergeWithInitialValue(parsedStoredValue, initialValue)
)
}
const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot)
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
(v: React.SetStateAction<T>) => {
try {
const nextState =
typeof v === 'function'
? (v as (prevState: T) => T)(JSON.parse(data))
: v
if (nextState === undefined || nextState === null) {
removeLocalStorageItem(key)
} else {
setLocalStorageItem(key, JSON.stringify(nextState))
}
} catch (e) {
console.warn(e)
}
},
[data, key]
)
React.useEffect(() => {
// Set local storage only when it's empty
const data = window.localStorage.getItem(key)
if (data === null) {
setLocalStorageItem(key, JSON.stringify(initialValue))
}
}, [key, initialValue])
const memoized = useMemo(() => JSON.parse(data) as T, [data])
return [memoized, setState]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

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

View File

@ -1,67 +0,0 @@
import React, { useMemo } from 'react'
import {
getSessionStorageItem,
mergeWithInitialValue,
removeSessionStorageItem,
setSessionStorageItem
} from 'utils'
const useSessionStorageSubscribe = (callback: () => void) => {
window.addEventListener('sessionStorage', callback)
return () => window.removeEventListener('sessionStorage', callback)
}
export function useSessionStorage<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const getSnapshot = () => {
// Get the stored value
const storedValue = getSessionStorageItem(key, initialValue)
// Parse the value
const parsedStoredValue = JSON.parse(storedValue)
// Merge the default and the stored in case some of the required fields are missing
return JSON.stringify(
mergeWithInitialValue(parsedStoredValue, initialValue)
)
}
const data = React.useSyncExternalStore(
useSessionStorageSubscribe,
getSnapshot
)
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
(v: React.SetStateAction<T>) => {
try {
const nextState =
typeof v === 'function'
? (v as (prevState: T) => T)(JSON.parse(data))
: v
if (nextState === undefined || nextState === null) {
removeSessionStorageItem(key)
} else {
setSessionStorageItem(key, JSON.stringify(nextState))
}
} catch (e) {
console.warn(e)
}
},
[data, key]
)
React.useEffect(() => {
// Set session storage only when it's empty
const data = window.sessionStorage.getItem(key)
if (data === null) {
setSessionStorageItem(key, JSON.stringify(initialValue))
}
}, [key, initialValue])
const memoized = useMemo(() => JSON.parse(data) as T, [data])
return [memoized, setState]
}

View File

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

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