Feat: Implemented WOT #121

Merged
enes merged 4 commits from wot into staging 2024-11-15 09:20:20 +00:00
23 changed files with 426 additions and 225 deletions
Showing only changes of commit 940f400300 - Show all commits

View File

@ -28,6 +28,8 @@ jobs:
echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_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_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env cat .env
- name: Create Build - name: Create Build

View File

@ -28,6 +28,8 @@ jobs:
echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_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_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env cat .env
- name: Create Build - name: Create Build

View File

@ -36,6 +36,8 @@ jobs:
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .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_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
cat .env cat .env
- name: Build - name: Build
run: npm run build run: npm run build

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -1,3 +1,4 @@
import { Dots } from 'components/Spinner'
import { useReactions } from 'hooks' import { useReactions } from 'hooks'
import { Addressable } from 'types' import { Addressable } from 'types'
@ -19,15 +20,13 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
aTag: addressable.aTag aTag: addressable.aTag
}) })
if (!isDataLoaded) return null
return ( return (
<> <>
<div <div
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${ className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : '' hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
}`} }`}
onClick={() => handleReaction(true)} onClick={isDataLoaded ? () => handleReaction(true) : undefined}
> >
<div className='IBMSMSMBSS_Details_CardVisual'> <div className='IBMSMSMBSS_Details_CardVisual'>
<svg <svg
@ -41,7 +40,9 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path> <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> </svg>
</div> </div>
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p> <p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
@ -50,7 +51,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${ className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : '' hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
}`} }`}
onClick={() => handleReaction()} onClick={isDataLoaded ? () => handleReaction() : undefined}
> >
<div className='IBMSMSMBSS_Details_CardVisual'> <div className='IBMSMSMBSS_Details_CardVisual'>
<svg <svg
@ -64,7 +65,9 @@ export const Reactions = ({ addressable }: ReactionsProps) => {
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path> <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> </svg>
</div> </div>
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p> <p className='IBMSMSMBSS_Details_CardText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>

View File

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

View File

@ -1,4 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk' import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Dots, Spinner } from 'components/Spinner'
import { ZapPopUp } from 'components/Zap' import { ZapPopUp } from 'components/Zap'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { import {
@ -59,6 +60,15 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
author: AuthorFilterEnum.All_Comments 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(() => { useEffect(() => {
setCommentCount(commentEvents.length) setCommentCount(commentEvents.length)
}, [commentEvents, setCommentCount]) }, [commentEvents, setCommentCount])
@ -175,8 +185,18 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
return true return true
} }
const handleDiscoveredClick = () => {
setVisible(commentEvents)
}
const [visible, setVisible] = useState<CommentEvent[]>([])
useEffect(() => {
if (isLoading) {
setVisible(commentEvents)
}
}, [commentEvents, isLoading])
const comments = useMemo(() => { const comments = useMemo(() => {
let filteredComments = commentEvents let filteredComments = visible
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
filteredComments = filteredComments.filter( filteredComments = filteredComments.filter(
(comment) => comment.pubkey === addressable.author (comment) => comment.pubkey === addressable.author
@ -190,13 +210,28 @@ export const Comments = ({ addressable, setCommentCount }: Props) => {
} }
return filteredComments return filteredComments
}, [commentEvents, filterOptions, addressable.author]) }, [visible, filterOptions.author, filterOptions.sort, addressable.author])
const discoveredCount = commentEvents.length - visible.length
return ( return (
<div className='IBMSMSMBSSCommentsWrapper'> <div className='IBMSMSMBSSCommentsWrapper'>
<h4 className='IBMSMSMBSSTitle'>Comments</h4> <h4 className='IBMSMSMBSSTitle'>Comments</h4>
<div className='IBMSMSMBSSComments'> <div className='IBMSMSMBSSComments'>
<CommentForm handleSubmit={handleSubmit} /> {/* Hide comment form if aTag is missing */}
{!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />}
<div>
{isLoading ? (
<Spinner />
) : (
<button
type='button'
className='btnMain'
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>Load {discoveredCount} discovered comments</span>
</button>
)}
</div>
<Filter <Filter
filterOptions={filterOptions} filterOptions={filterOptions}
setFilterOptions={setFilterOptions} setFilterOptions={setFilterOptions}
@ -355,12 +390,12 @@ const Comment = (props: CommentEvent) => {
</div> </div>
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'> <div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
<div className='IBMSMSMBSSCL_CommentTopDetails'> <div className='IBMSMSMBSSCL_CommentTopDetails'>
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'> <Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
{profile?.displayName || profile?.name || ''}{' '} {profile?.displayName || profile?.name || ''}{' '}
</a> </Link>
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'> <Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
{hexToNpub(props.pubkey)} {hexToNpub(props.pubkey)}
</a> </Link>
</div> </div>
<div className='IBMSMSMBSSCL_CommentActionsDetails'> <div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'> <a className='IBMSMSMBSSCL_CADTime'>
@ -446,15 +481,13 @@ const Reactions = (props: Event) => {
eTag: props.id eTag: props.id
}) })
if (!isDataLoaded) return null
return ( return (
<> <>
<div <div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${ className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : '' hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
}`} }`}
onClick={() => handleReaction(true)} onClick={isDataLoaded ? () => handleReaction(true) : undefined}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -466,7 +499,9 @@ const Reactions = (props: Event) => {
> >
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path> <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> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>{likesCount}</p> <p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? likesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
@ -475,7 +510,7 @@ const Reactions = (props: Event) => {
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${ className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : '' hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
}`} }`}
onClick={() => handleReaction()} onClick={isDataLoaded ? () => handleReaction() : undefined}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -487,7 +522,9 @@ const Reactions = (props: Event) => {
> >
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path> <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> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>{disLikesCount}</p> <p className='IBMSMSMBSSCL_CAElementText'>
{isDataLoaded ? disLikesCount : <Dots />}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>

View File

@ -21,7 +21,12 @@ export const LANDING_PAGE_DATA = {
'ELDEN RING', 'ELDEN RING',
'The Coffin of Andy and Leyley' 'The Coffin of Andy and Leyley'
], ],
featuredBlogPosts: [] featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2573jhg9trsu6vgav9gnn4dffkzk2ww3yrjejnc2s'
]
} }
// we use this object to check if a user has reacted positively or negatively to a post // we use this object to check if a user has reacted positively or negatively to a post
// reactions are kind 7 events and their content is either emoji icon or emoji shortcode // reactions are kind 7 events and their content is either emoji icon or emoji shortcode

View File

@ -21,7 +21,8 @@ import {
log, log,
LogType, LogType,
npubToHex, npubToHex,
orderEventsChronologically orderEventsChronologically,
timeout
} from 'utils' } from 'utils'
type FetchModsOptions = { type FetchModsOptions = {
@ -241,8 +242,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
hexKey: string, hexKey: string,
userRelaysType: UserRelaysType userRelaysType: UserRelaysType
): Promise<NDKEvent[]> => { ): Promise<NDKEvent[]> => {
// Find the user's relays. // Find the user's relays (10s timeout).
const relayUrls = await getRelayListForUser(hexKey, ndk) const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(10000)
])
.then((ndkRelayList) => { .then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType] if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined return [] // Return an empty array if ndkRelayList is undefined

View File

@ -5,7 +5,7 @@ import { Event, kinds, UnsignedEvent } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserRelaysType } from 'types' import { UserRelaysType } from 'types'
import { abbreviateNumber, log, LogType, now } from 'utils' import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
type UseReactionsParams = { type UseReactionsParams = {
pubkey: string pubkey: string
@ -32,7 +32,11 @@ export const useReactions = (params: UseReactionsParams) => {
filter['#e'] = [params.eTag] filter['#e'] = [params.eTag]
} }
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) // 1 minute timeout
Promise.race([
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
timeout(60000)
])
.then((events) => { .then((events) => {
setReactionEvents(events) setReactionEvents(events)
}) })

View File

@ -381,16 +381,14 @@ const RegisterButtonWithDialog = () => {
Browser Extensions (Windows) Browser Extensions (Windows)
</label> </label>
<p className='labelDescriptionMain'> <p className='labelDescriptionMain'>
Once you create your "account" on any of these ( Once you create your "account" on any of these, come back and click login, then sign-in with
<a extension. Here's a quick video guide, and here's a <a
href='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4' href='https://degmods.com/blog/naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c'
target='blank_' >guide post</a> to help with this process.</p>
> <div style={{ width: '100%', height: 'auto', borderRadius: '8px', overflow: 'hidden' }}>
Here's a quick video guide <video controls style={{ width: '100%' }}><source src="https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4" type="video/mp4" />
</a> Your browser does not support the video tag.</video>
), come back and click login, then sign-in with </div>
extension.
</p>
</div> </div>
<a <a
className='btn btnMain btnMainPopup' className='btn btnMain btnMainPopup'

View File

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

View File

@ -94,9 +94,7 @@ export const BlogPage = () => {
<div className='ContainerMain'> <div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'> <div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'> <div className='IBMSMSplitMain'>
{!blog ? ( {blog && (
<LoadingSpinner desc={'Loading...'} />
) : (
<> <>
<div className='IBMSMSplitMainBigSide'> <div className='IBMSMSplitMainBigSide'>
<div className='IBMSMSplitMainBigSideSec'> <div className='IBMSMSplitMainBigSideSec'>
@ -301,6 +299,7 @@ export const BlogPage = () => {
)} )}
<div className='IBMSMSplitMainBigSideSec'> <div className='IBMSMSplitMainBigSideSec'>
<Comments <Comments
key={blog.id}
addressable={blog as Addressable} addressable={blog as Addressable}
setCommentCount={setCommentCount} setCommentCount={setCommentCount}
/> />

View File

@ -1,11 +1,16 @@
import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' import { NDKFilter } from '@nostr-dev-kit/ndk'
import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants'
import { NDKContextType } from 'contexts/NDKContext' import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify'
import { appRoutes } from 'routes' import { appRoutes } from 'routes'
import { store } from 'store' import { store } from 'store'
import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' import {
BlogPageLoaderResult,
FilterOptions,
ModeratedFilter,
NSFWFilter
} from 'types'
import { import {
DEFAULT_FILTER_OPTIONS, DEFAULT_FILTER_OPTIONS,
getLocalStorageItem, getLocalStorageItem,
@ -16,62 +21,74 @@ import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog'
export const blogRouteLoader = export const blogRouteLoader =
(ndkContext: NDKContextType) => (ndkContext: NDKContextType) =>
async ({ params }: LoaderFunctionArgs) => { async ({ params, request }: LoaderFunctionArgs) => {
const { naddr } = params const { naddr } = params
if (!naddr) { if (!naddr) {
log(true, LogType.Error, 'Required naddr.') log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs) return redirect(appRoutes.blogs)
} }
// Decode author from naddr // Decode author and identifier from naddr
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) let pubkey: string | undefined
const { pubkey } = decoded.data let identifier: string | undefined
try { try {
// Get the filter with #a from naddr for the main blog content const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const filter = filterForEventsTaggingId(naddr) pubkey = decoded.data.pubkey
if (!filter) { identifier = decoded.data.identifier
log(true, LogType.Error, 'Unable to create filter from blog naddr.') } catch (error) {
return redirect(appRoutes.blogs) log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error)
throw new Error('Failed to fetch the blog. The address might be wrong')
} }
// Update kinds to make sure we fetch correct event kind
filter.kinds = [kinds.LongFormArticle]
const userState = store.getState().user const userState = store.getState().user
const loggedInUserPubkey = userState?.user?.pubkey as string | undefined
// Check if editing and the user is the original author
// Redirect if NOT
const url = new URL(request.url)
const isEditMode = url.pathname.endsWith('/edit')
if (isEditMode && loggedInUserPubkey !== pubkey) {
return redirect(appRoutes.blogs)
}
try {
// Set the filter for the main blog content
const filter = {
kinds: [kinds.LongFormArticle],
authors: [pubkey],
'#d': [identifier]
}
// Get the blog filter options for latest blogs // Get the blog filter options for latest blogs
const filterOptions = JSON.parse( const filterOptions = JSON.parse(
getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS) getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS)
) as FilterOptions ) as FilterOptions
// Fetch 4 in case the current blog is included in the latest // Fetch more in case the current blog is included in the latest and filters remove some
const latestModsFilter: NDKFilter = { const latestFilter: NDKFilter = {
authors: [pubkey], authors: [pubkey],
kinds: [kinds.LongFormArticle], kinds: [kinds.LongFormArticle],
limit: 4 limit: PROFILE_BLOG_FILTER_LIMIT
} }
// Add source filter // Add source filter
if (filterOptions.source === window.location.host) { if (filterOptions.source === window.location.host) {
latestModsFilter['#r'] = [filterOptions.source] latestFilter['#r'] = [filterOptions.source]
} }
// Filter by NSFW tag // Filter by NSFW tag
// NSFWFilter.Only_NSFW -> fetch with content-warning label
// NSFWFilter.Show_NSFW -> filter not needed // NSFWFilter.Show_NSFW -> filter not needed
// NSFWFilter.Only_NSFW -> true // NSFWFilter.Hide_NSFW -> up the limit and filter after fetch
// NSFWFilter.Hide_NSFW -> false if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { latestFilter['#L'] = ['content-warning']
latestModsFilter['#nsfw'] = [
(filterOptions.nsfw === NSFWFilter.Only_NSFW).toString()
]
} }
// Parallel fetch blog event, latest events, mute, and nsfw lists in parallel // Parallel fetch blog event, latest events, mute, and nsfw lists
const settled = await Promise.allSettled([ const settled = await Promise.allSettled([
ndkContext.fetchEvent(filter), ndkContext.fetchEvent(filter),
ndkContext.fetchEvents(latestModsFilter), ndkContext.fetchEvents(latestFilter),
ndkContext.getMuteLists(userState?.user?.pubkey as string), ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users
ndkContext.getNSFWList() ndkContext.getNSFWList()
]) ])
const result: BlogPageLoaderResult = { const result: BlogPageLoaderResult = {
blog: undefined, blog: undefined,
latest: [], latest: [],
@ -93,6 +110,12 @@ export const blogRouteLoader =
) )
} }
// Throw an error if we are missing the main blog result
// Handle it with the react-router's errorComponent
if (!result.blog) {
throw new Error('We are unable to find the blog on the relays')
}
// Check the lateast blog events // Check the lateast blog events
const fetchEventsResult = settled[1] const fetchEventsResult = settled[1]
if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) {
@ -100,7 +123,6 @@ export const blogRouteLoader =
result.latest = fetchEventsResult.value result.latest = fetchEventsResult.value
.map(extractBlogCardDetails) .map(extractBlogCardDetails)
.filter((b) => b.id !== result.blog?.id) // Filter out current blog if present .filter((b) => b.id !== result.blog?.id) // Filter out current blog if present
.slice(0, 3) // Take only three
} else if (fetchEventsResult.status === 'rejected') { } else if (fetchEventsResult.status === 'rejected') {
log( log(
true, true,
@ -110,22 +132,48 @@ export const blogRouteLoader =
) )
} }
const muteList = settled[2] const muteLists = settled[2]
if (muteList.status === 'fulfilled' && muteList.value) { if (muteLists.status === 'fulfilled' && muteLists.value) {
if (muteList && muteList.value) { if (muteLists && muteLists.value) {
if (result.blog && result.blog.aTag) { if (result.blog && result.blog.aTag) {
if ( if (
muteList.value.admin.replaceableEvents.includes( muteLists.value.admin.replaceableEvents.includes(
result.blog.aTag result.blog.aTag
) || ) ||
muteList.value.user.replaceableEvents.includes(result.blog.aTag) muteLists.value.user.replaceableEvents.includes(result.blog.aTag)
) { ) {
result.isBlocked = true result.isBlocked = true
} }
} }
// Moderate the latest
const isAdmin =
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isOwner =
userState.user?.pubkey && userState.user.pubkey === pubkey
const isUnmoderatedFully =
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
// Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.admin.authors.includes(b.author!) &&
!muteLists.value.admin.replaceableEvents.includes(b.aTag!)
)
} }
} else if (muteList.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching mute list', muteList.reason) if (filterOptions.moderated === ModeratedFilter.Moderated) {
result.latest = result.latest.filter(
(b) =>
!muteLists.value.user.authors.includes(b.author!) &&
!muteLists.value.user.replaceableEvents.includes(b.aTag!)
)
}
}
} else if (muteLists.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason)
} }
const nsfwList = settled[3] const nsfwList = settled[3]
@ -147,31 +195,39 @@ export const blogRouteLoader =
} }
} }
// Check if the the latest blogs too // Check the latest blogs too
result.latest = result.latest.map((b) => { result.latest = result.latest.map((b) => {
if (b) { // Add nsfw tag if it's missing
const isMissingNsfwTag = const isMissingNsfwTag =
!b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag)
if (isMissingNsfwTag) { if (isMissingNsfwTag) {
b.nsfw = true b.nsfw = true
} }
}
return b return b
}) })
} else if (nsfwList.status === 'rejected') { } else if (nsfwList.status === 'rejected') {
log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason)
} }
// Filter latest, sort and take only three
result.latest = result.latest
.filter(
// Filter out the NSFW if selected
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
.sort((a, b) =>
a.published_at && b.published_at ? b.published_at - a.published_at : 0
)
.slice(0, 3)
return result return result
} catch (error) { } catch (error) {
log( let message = 'An error occurred in fetching blog details from relays'
true, log(true, LogType.Error, message, error)
LogType.Error, if (error instanceof Error) {
'An error occurred in fetching blog details from relays', message = error.message
error throw new Error(message)
) }
toast.error('An error occurred in fetching blog details from relays')
return redirect(appRoutes.blogs)
} }
} }

View File

@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useLoaderData, useSearchParams } from 'react-router-dom' import { useLoaderData, useNavigation, useSearchParams } from 'react-router-dom'
import { useLocalStorage } from 'hooks' import { useLocalStorage } from 'hooks'
import { BlogCardDetails, NSFWFilter, SortBy } from 'types' import { BlogCardDetails, NSFWFilter, SortBy } from 'types'
import { SearchInput } from '../../components/SearchInput' import { SearchInput } from '../../components/SearchInput'
@ -10,8 +10,10 @@ import '../../styles/search.css'
import '../../styles/styles.css' import '../../styles/styles.css'
import { PaginationWithPageNumbers } from 'components/Pagination' import { PaginationWithPageNumbers } from 'components/Pagination'
import { scrollIntoView } from 'utils' import { scrollIntoView } from 'utils'
import { LoadingSpinner } from 'components/LoadingSpinner'
export const BlogsPage = () => { export const BlogsPage = () => {
const navigation = useNavigation()
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
const [filterOptions, setFilterOptions] = useLocalStorage( const [filterOptions, setFilterOptions] = useLocalStorage(
'filter-blog-curated', 'filter-blog-curated',
@ -105,6 +107,7 @@ export const BlogsPage = () => {
return ( return (
<div className='InnerBodyMain'> <div className='InnerBodyMain'>
{navigation.state !== 'idle' && <LoadingSpinner desc={'Loading'} />}
<div className='ContainerMain'> <div className='ContainerMain'>
<div <div
className='IBMSecMainGroup IBMSecMainGroupAlt' className='IBMSecMainGroup IBMSecMainGroupAlt'

View File

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

View File

@ -1,12 +1,12 @@
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate, useNavigation } from 'react-router-dom'
import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import { BlogCard } from '../components/BlogCard' import { BlogCard } from '../components/BlogCard'
import { GameCard } from '../components/GameCard' import { GameCard } from '../components/GameCard'
import { ModCard } from '../components/ModCard' import { ModCard } from '../components/ModCard'
import { LANDING_PAGE_DATA } from '../constants' import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants'
import { import {
useAppSelector, useAppSelector,
useDidMount, useDidMount,
@ -32,14 +32,12 @@ import '../styles/SimpleSlider.css'
import '../styles/styles.css' import '../styles/styles.css'
// Import Swiper styles // Import Swiper styles
import { import { NDKFilter } from '@nostr-dev-kit/ndk'
filterForEventsTaggingId,
NDKEvent,
NDKFilter
} from '@nostr-dev-kit/ndk'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import 'swiper/css/pagination' import 'swiper/css/pagination'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { Spinner } from 'components/Spinner'
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -260,6 +258,8 @@ const DisplayLatestMods = () => {
useDidMount(() => { useDidMount(() => {
fetchMods({ source: window.location.host }) fetchMods({ source: window.location.host })
.then((mods) => { .then((mods) => {
// Sort by the latest (published_at descending)
mods.sort((a, b) => b.published_at - a.published_at)
const wotFilteredMods = mods.filter( const wotFilteredMods = mods.filter(
(mod) => siteWot.includes(mod.author) || userWot.includes(mod.author) (mod) => siteWot.includes(mod.author) || userWot.includes(mod.author)
) )
@ -316,14 +316,6 @@ const DisplayLatestMods = () => {
) )
} }
const Spinner = () => {
return (
<div className='spinner'>
<div className='spinnerCircle'></div>
</div>
)
}
const DisplayLatestBlogs = () => { const DisplayLatestBlogs = () => {
const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>() const [blogs, setBlogs] = useState<Partial<BlogCardDetails>[]>()
const { fetchEvents } = useNDKContext() const { fetchEvents } = useNDKContext()
@ -331,86 +323,92 @@ const DisplayLatestBlogs = () => {
sort: SortBy.Latest, sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW nsfw: NSFWFilter.Hide_NSFW
}) })
const navigation = useNavigation()
useDidMount(() => { useDidMount(() => {
const fetchBlogs = async () => { const fetchBlogs = async () => {
try { try {
// Show maximum of 4 blog posts // Show maximum of 4 blog posts
// 2 should be featured and the most recent 2 from blog npubs // 2 should be featured and the most recent 2 from blog npubs
// Populate the filter from known naddr (constants.ts) // Populate the filter from known naddr (constants.ts)
const filters: NDKFilter[] = [] const filter: NDKFilter = {
kinds: [kinds.LongFormArticle],
authors: [],
'#d': []
}
for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) { for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) {
try { try {
const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i] const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i]
const filterId = filterForEventsTaggingId(naddr) const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
if (filterId) { const { pubkey, identifier } = decoded.data
filters.push(filterId) if (!filter.authors?.includes(pubkey)) {
filter.authors?.push(pubkey)
}
if (!filter.authors?.includes(identifier)) {
filter['#d']?.push(identifier)
} }
} catch (error) { } catch (error) {
// Silently ignore // Silently ignore
} }
} }
// Create a single filter based on multiple #a's
const filter = filters.reduce(
(filter, id) => {
const a = id['#a']
if (a) {
filter['#a']?.push(a[0])
}
return filter
},
{
'#a': []
} as NDKFilter
)
// Prepare filter for the latest // Prepare filter for the latest
const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',')
const blogHexkeys = blogNpubs const blogHexkeys = blogNpubs
.map(npubToHex) .map(npubToHex)
.filter((hexkey) => hexkey !== null) .filter((hexkey) => hexkey !== null)
// We fetch 4 posts in case of duplicates (from featured) // We fetch more posts in case of duplicates (from featured)
const latestFilter: NDKFilter = { const latestFilter: NDKFilter = {
authors: blogHexkeys, authors: blogHexkeys,
kinds: [kinds.LongFormArticle], kinds: [kinds.LongFormArticle],
limit: 4 limit: PROFILE_BLOG_FILTER_LIMIT
} }
// Filter by NSFW tag // Filter by NSFW tag
// NSFWFilter.Show_NSFW -> filter not needed if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
// NSFWFilter.Only_NSFW -> true latestFilter['#L'] = ['content-warning']
// NSFWFilter.Hide_NSFW -> false
if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) {
latestFilter['#nsfw'] = [
(filterOptions.nsfw === NSFWFilter.Only_NSFW).toString()
]
} }
const results = await Promise.allSettled([ const results = await Promise.allSettled([
fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), fetchEvents(filter),
fetchEvents(latestFilter) fetchEvents(latestFilter)
]) ])
const events: NDKEvent[] = [] const events: Partial<BlogCardDetails>[] = []
// Get featured blogs posts result // Get featured blogs posts result
results.forEach((r) => { results.forEach((r) => {
// Add events from both promises to the array // Add events from both promises to the array
if (r.status === 'fulfilled' && r.value) { if (r.status === 'fulfilled' && r.value) {
events.push(...r.value) events.push(
...r.value
.map(extractBlogCardDetails) // Extract the blog card details
.sort(
// Sort each result by published_at in descending order
// We can't sort everything at once we'd lose prefered
(a, b) =>
a.published_at && b.published_at
? b.published_at - a.published_at
: 0
)
)
} }
}) })
// Remove duplicates // Remove duplicates
const unique = Array.from( const unique = Array.from(
events events
.filter((b) => b.id)
.reduce((map, obj) => { .reduce((map, obj) => {
map.set(obj.id, obj) map.set(obj.id!, obj)
return map return map
}, new Map()) }, new Map<string, Partial<BlogCardDetails>>())
.values() .values()
).filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
) )
const latest = unique.slice(0, 4)
setBlogs(latest.map(extractBlogCardDetails)) const latest = unique.slice(0, 4)
setBlogs(latest)
} catch (error) { } catch (error) {
log( log(
true, true,
@ -427,6 +425,9 @@ const DisplayLatestBlogs = () => {
return ( return (
<div className='IBMSecMain IBMSMListWrapper'> <div className='IBMSecMain IBMSMListWrapper'>
{navigation.state !== 'idle' && (
<LoadingSpinner desc={'Fetching blog...'} />
)}
<div className='IBMSMTitleMain'> <div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>Blog Posts</h2> <h2 className='IBMSMTitleMainHeading'>Blog Posts</h2>
</div> </div>

View File

@ -14,11 +14,11 @@ import {
useNDKContext, useNDKContext,
useNSFWList useNSFWList
} from 'hooks' } from 'hooks'
import { kinds, nip19, UnsignedEvent } from 'nostr-tools' import { kinds, UnsignedEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom' import { Link, useLoaderData, useNavigation } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes } from 'routes'
import { import {
BlogCardDetails, BlogCardDetails,
FilterOptions, FilterOptions,
@ -32,8 +32,6 @@ import {
copyTextToClipboard, copyTextToClipboard,
DEFAULT_FILTER_OPTIONS, DEFAULT_FILTER_OPTIONS,
extractBlogCardDetails, extractBlogCardDetails,
log,
LogType,
now, now,
npubToHex, npubToHex,
scrollIntoView, scrollIntoView,
@ -46,23 +44,11 @@ import { BlogCard } from 'components/BlogCard'
export const ProfilePage = () => { export const ProfilePage = () => {
const { const {
profilePubkey,
profile, profile,
isBlocked: _isBlocked, isBlocked: _isBlocked,
isOwnProfile isOwnProfile
} = useLoaderData() as ProfilePageLoaderResult } = useLoaderData() as ProfilePageLoaderResult
// Try to decode nprofile parameter
const { nprofile } = useParams()
let profilePubkey: string | undefined
try {
const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`)
: undefined
profilePubkey = value?.data.pubkey
} catch (error) {
// Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error)
}
const scrollTargetRef = useRef<HTMLDivElement>(null) const scrollTargetRef = useRef<HTMLDivElement>(null)
const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
@ -262,6 +248,7 @@ export const ProfilePage = () => {
setIsLoading(true) setIsLoading(true)
switch (tab) { switch (tab) {
case 0: case 0:
setLoadingSpinnerDesc('Fetching mods..')
fetchMods({ source: filterOptions.source, author: profilePubkey }) fetchMods({ source: filterOptions.source, author: profilePubkey })
.then((res) => { .then((res) => {
setMods(res) setMods(res)
@ -285,22 +272,6 @@ export const ProfilePage = () => {
profilePubkey profilePubkey
) )
// Redirect route
let profileRoute = appRoutes.home
if (!nprofile && userState.auth && userState.user) {
// Redirect to user's profile is no profile is linked
const userHexKey = npubToHex(userState.user.npub as string)
if (userHexKey) {
profileRoute = getProfilePageRoute(
nip19.nprofileEncode({
pubkey: userHexKey
})
)
}
}
if (!profilePubkey) return <Navigate to={profileRoute} replace={true} />
return ( return (
<div className='InnerBodyMain'> <div className='InnerBodyMain'>
<div className='ContainerMain'> <div className='ContainerMain'>
@ -690,6 +661,7 @@ const ReportUserPopup = ({
const ProfileTabBlogs = () => { const ProfileTabBlogs = () => {
const { profile, muteLists, nsfwList } = const { profile, muteLists, nsfwList } =
useLoaderData() as ProfilePageLoaderResult useLoaderData() as ProfilePageLoaderResult
const navigation = useNavigation()
const { fetchEvents } = useNDKContext() const { fetchEvents } = useNDKContext()
const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -704,10 +676,8 @@ const ProfileTabBlogs = () => {
filter['#r'] = [host] filter['#r'] = [host]
} }
if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { if (filterOptions.nsfw === NSFWFilter.Only_NSFW) {
filter['#nsfw'] = [ filter['#L'] = ['content-warning']
(filterOptions.nsfw === NSFWFilter.Only_NSFW).toString()
]
} }
return filter return filter
@ -725,7 +695,7 @@ const ProfileTabBlogs = () => {
} }
fetchEvents(filter) fetchEvents(filter)
.then((events) => { .then((events) => {
setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT)
}) })
.finally(() => { .finally(() => {
@ -752,7 +722,7 @@ const ProfileTabBlogs = () => {
setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT)
setPage((prev) => prev + 1) setPage((prev) => prev + 1)
setBlogs( setBlogs(
nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr)
) )
}) })
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
@ -775,7 +745,7 @@ const ProfileTabBlogs = () => {
.then((events) => { .then((events) => {
setHasMore(true) setHasMore(true)
setPage((prev) => prev - 1) setPage((prev) => prev - 1)
setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr))
}) })
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
} }
@ -799,6 +769,11 @@ const ProfileTabBlogs = () => {
}) })
} }
// Filter nsfw (Hide_NSFW option)
_blogs = _blogs.filter(
(b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW)
)
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" // 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 // Allow "Unmoderated Fully" when author visits own profile
if (!((isAdmin || isOwner) && isUnmoderatedFully)) { if (!((isAdmin || isOwner) && isUnmoderatedFully)) {
@ -845,7 +820,9 @@ const ProfileTabBlogs = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={'Fetching blogs...'} />} {(isLoading || navigation.state !== 'idle') && (
<LoadingSpinner desc={'Loading...'} />
)}
<ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} /> <ModFilter filterKey={'filter-blog'} author={profile?.pubkey as string} />

View File

@ -4,9 +4,10 @@ import { LoaderFunctionArgs, redirect } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes, getProfilePageRoute } from 'routes'
import { store } from 'store' import { store } from 'store'
import { MuteLists, UserProfile } from 'types' import { MuteLists, UserProfile } from 'types'
import { log, LogType } from 'utils' import { log, LogType, npubToHex } from 'utils'
export interface ProfilePageLoaderResult { export interface ProfilePageLoaderResult {
profilePubkey: string
profile: UserProfile profile: UserProfile
isBlocked: boolean isBlocked: boolean
isOwnProfile: boolean isOwnProfile: boolean
@ -24,10 +25,25 @@ export const profileRouteLoader =
const { nprofile } = params const { nprofile } = params
let profilePubkey: string | undefined let profilePubkey: string | undefined
try { try {
// Decode if it starts with nprofile1
if (nprofile?.startsWith('nprofile1')) {
const value = nprofile const value = nprofile
? nip19.decode(nprofile as `nprofile1${string}`) ? nip19.decode(nprofile as `nprofile1${string}`)
: undefined : undefined
profilePubkey = value?.data.pubkey profilePubkey = value?.data.pubkey
} else if (nprofile?.startsWith('npub1')) {
// Try to get hex from the npub and encode it to nprofile
const value = npubToHex(nprofile)
if (value) {
return redirect(
getProfilePageRoute(
nip19.nprofileEncode({
pubkey: value
})
)
)
}
}
} catch (error) { } catch (error) {
// Silently ignore and redirect to home or logged in user // Silently ignore and redirect to home or logged in user
log(true, LogType.Error, 'Failed to decode nprofile.', error) log(true, LogType.Error, 'Failed to decode nprofile.', error)
@ -57,6 +73,7 @@ export const profileRouteLoader =
// Empty result // Empty result
const result: ProfilePageLoaderResult = { const result: ProfilePageLoaderResult = {
profilePubkey: profilePubkey,
profile: {}, profile: {},
isBlocked: false, isBlocked: false,
isOwnProfile: false, isOwnProfile: false,

View File

@ -84,12 +84,7 @@ export const writeRouteAction =
.split(',') .split(',')
.map((t) => ['t', t]) .map((t) => ['t', t])
const unsignedEvent: UnsignedEvent = { const tags = [
kind: kinds.LongFormArticle,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: content,
tags: [
['d', uuid], ['d', uuid],
['a', aTag], ['a', aTag],
['r', rTag], ['r', rTag],
@ -97,9 +92,19 @@ export const writeRouteAction =
['title', formSubmit.title!], ['title', formSubmit.title!],
['image', formSubmit.image!], ['image', formSubmit.image!],
['summary', formSubmit.summary!], ['summary', formSubmit.summary!],
['nsfw', (formSubmit.nsfw === 'on').toString()],
...tTags ...tTags
] ]
// Add NSFW tag, L label namespace standardized tag
// https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags
if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning'])
const unsignedEvent: UnsignedEvent = {
kind: kinds.LongFormArticle,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: content,
tags: tags
} }
try { try {

View File

@ -103,13 +103,15 @@ export const routerWithNdkContext = (context: NDKContextType) =>
path: appRoutes.blog, path: appRoutes.blog,
element: <BlogPage />, element: <BlogPage />,
loader: blogRouteLoader(context), loader: blogRouteLoader(context),
action: blogRouteAction(context) action: blogRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
}, },
{ {
path: appRoutes.blogEdit, path: appRoutes.blogEdit,
element: <WritePage key='edit' />, element: <WritePage key='edit' />,
loader: blogRouteLoader(context), loader: blogRouteLoader(context),
action: writeRouteAction(context) action: writeRouteAction(context),
errorElement: <NotFoundPage title={'Something went wrong.'} />
}, },
{ {
path: appRoutes.blogReport_actionOnly, path: appRoutes.blogReport_actionOnly,

View File

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

View File

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