chore(git): merge pull request #231 from feat/131-feed-issues into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s

Reviewed-on: #231
This commit is contained in:
enes 2025-02-21 14:06:33 +00:00
commit e6b27af709
26 changed files with 1213 additions and 273 deletions

18
package-lock.json generated
View File

@ -10,8 +10,8 @@
"dependencies": { "dependencies": {
"@getalby/lightning-tools": "5.0.3", "@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0", "@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.11.0", "@nostr-dev-kit/ndk": "2.11.2",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@nostr-dev-kit/ndk-cache-dexie": "2.5.11",
"@reduxjs/toolkit": "2.2.6", "@reduxjs/toolkit": "2.2.6",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"axios": "^1.7.9", "axios": "^1.7.9",
@ -2134,9 +2134,9 @@
} }
}, },
"node_modules/@nostr-dev-kit/ndk": { "node_modules/@nostr-dev-kit/ndk": {
"version": "2.11.0", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.2.tgz",
"integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==", "integrity": "sha512-DNrodIBC0j2MqEUQ5Mqaa671iZiRiKluu0c/wLkX7PCva07KSPyvcuyGp5fhk+/EZBurwZccMaML0syH0Qu8kQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
@ -2156,12 +2156,12 @@
} }
}, },
"node_modules/@nostr-dev-kit/ndk-cache-dexie": { "node_modules/@nostr-dev-kit/ndk-cache-dexie": {
"version": "2.5.9", "version": "2.5.11",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.9.tgz", "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.11.tgz",
"integrity": "sha512-SZ5FjON0QPekiC7oW9Hy3JQxG0Oxxtud9LBa1q/A49JV/Qppv1x37nFHxi0XLxEbDgFTNYbaN27Zjfp2NPem2g==", "integrity": "sha512-lhoKcjwxlNB2rrnZ2zDAGJeh5k7x1f51oAwUnlDAuPvNEe4q/2XynxnI3uTe7rBg9+pq085esOQK7pg75E+BgQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nostr-dev-kit/ndk": "2.11.0", "@nostr-dev-kit/ndk": "2.11.2",
"debug": "^4.3.7", "debug": "^4.3.7",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"nostr-tools": "^2.4.0", "nostr-tools": "^2.4.0",

View File

@ -12,8 +12,8 @@
"dependencies": { "dependencies": {
"@getalby/lightning-tools": "5.0.3", "@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0", "@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.11.0", "@nostr-dev-kit/ndk": "2.11.2",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9", "@nostr-dev-kit/ndk-cache-dexie": "2.5.11",
"@reduxjs/toolkit": "2.2.6", "@reduxjs/toolkit": "2.2.6",
"@types/react-helmet": "^6.1.11", "@types/react-helmet": "^6.1.11",
"axios": "^1.7.9", "axios": "^1.7.9",

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { PropsWithChildren } from 'react' import { PropsWithChildren } from 'react'
import { Filter } from '.' import { Filter } from '.'
import { FilterOptions, SortBy } from 'types' import { FilterOptions, RepostFilter, SortBy } from 'types'
import { Dropdown } from './Dropdown' import { Dropdown } from './Dropdown'
import { Option } from './Option' import { Option } from './Option'
import { DEFAULT_FILTER_OPTIONS } from 'utils' import { DEFAULT_FILTER_OPTIONS } from 'utils'
@ -43,10 +43,19 @@ export const FeedFilter = React.memo(
{/* nsfw filter options */} {/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}> <Dropdown label={filterOptions.nsfw}>
<NsfwFilterOptions filterKey={filterKey} /> <NsfwFilterOptions
filterKey={filterKey}
{...(tab === 2
? {
skipOnlyNsfw: true
}
: {})}
/>
</Dropdown> </Dropdown>
{/* source filter options */} {/* source filter options */}
{/* show only if not posts tabs */}
{tab !== 2 && (
<Dropdown <Dropdown
label={ label={
filterOptions.source === window.location.host filterOptions.source === window.location.host
@ -75,6 +84,28 @@ export const FeedFilter = React.memo(
Show All Show All
</Option> </Option>
</Dropdown> </Dropdown>
)}
{/* Repost filter */}
{tab === 2 && (
<Dropdown label={filterOptions.repost}>
{Object.values(RepostFilter).map((item, index) =>
item === RepostFilter.Only_Repost ? null : (
<Option
key={`repost-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
repost: item
}))
}
>
{item}
</Option>
)
)}
</Dropdown>
)}
{children} {children}
</Filter> </Filter>

View File

@ -7,9 +7,13 @@ import { DEFAULT_FILTER_OPTIONS } from 'utils'
interface NsfwFilterOptionsProps { interface NsfwFilterOptionsProps {
filterKey: string filterKey: string
skipOnlyNsfw?: boolean
} }
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { export const NsfwFilterOptions = ({
filterKey,
skipOnlyNsfw
}: NsfwFilterOptionsProps) => {
const [, setFilterOptions] = useLocalStorage<FilterOptions>( const [, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey, filterKey,
DEFAULT_FILTER_OPTIONS DEFAULT_FILTER_OPTIONS
@ -30,7 +34,11 @@ export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
return ( return (
<> <>
{Object.values(NSFWFilter).map((item, index) => ( {Object.values(NSFWFilter).map((item, index) => {
// Posts feed filter exception
if (item === NSFWFilter.Only_NSFW && skipOnlyNsfw) return null
return (
<Option <Option
key={`nsfwFilterItem-${index}`} key={`nsfwFilterItem-${index}`}
onClick={() => { onClick={() => {
@ -52,7 +60,8 @@ export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
> >
{item} {item}
</Option> </Option>
))} )
})}
{showNsfwPopup && ( {showNsfwPopup && (
<NsfwAlertPopup <NsfwAlertPopup
handleConfirm={handleConfirm} handleConfirm={handleConfirm}

View File

@ -9,16 +9,22 @@ import { Reactions } from 'components/comment/Reactions'
import { Zap } from 'components/comment/Zap' import { Zap } from 'components/comment/Zap'
import { Dots } from 'components/Spinner' import { Dots } from 'components/Spinner'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { useAppSelector, useDidMount, useNDKContext } from 'hooks' import {
useAppSelector,
useDidMount,
useLocalStorage,
useNDKContext
} from 'hooks'
import { useComments } from 'hooks/useComments' import { useComments } from 'hooks/useComments'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigation, useSubmit } from 'react-router-dom'
import { appRoutes, getProfilePageRoute } from 'routes' import { appRoutes, getProfilePageRoute } from 'routes'
import { UserProfile } from 'types' import { FeedPostsFilter, NSFWFilter, UserProfile } from 'types'
import { hexToNpub } from 'utils' import { DEFAULT_FILTER_OPTIONS, hexToNpub, log, LogType } from 'utils'
import { NoteRepostPopup } from './NoteRepostPopup' import { NoteRepostPopup } from './NoteRepostPopup'
import { NoteQuoteRepostPopup } from './NoteQuoteRepostPopup' import { NoteQuoteRepostPopup } from './NoteQuoteRepostPopup'
import { NsfwCommentWrapper } from 'components/NsfwCommentWrapper'
interface NoteProps { interface NoteProps {
ndkEvent: NDKEvent ndkEvent: NDKEvent
@ -26,10 +32,20 @@ interface NoteProps {
export const Note = ({ ndkEvent }: NoteProps) => { export const Note = ({ ndkEvent }: NoteProps) => {
const { ndk } = useNDKContext() const { ndk } = useNDKContext()
const submit = useSubmit()
const navigation = useNavigation()
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined const userPubkey = userState.user?.pubkey as string | undefined
const [eventProfile, setEventProfile] = useState<UserProfile>() const [eventProfile, setEventProfile] = useState<UserProfile>()
const isRepost = ndkEvent.kind === NDKKind.Repost const isRepost = ndkEvent.kind === NDKKind.Repost
const filterKey = 'filter-feed-2'
const [filterOptions] = useLocalStorage<FeedPostsFilter>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
const isNsfw = ndkEvent
.getMatchingTags('L')
.some((t) => t[1] === 'content-warning')
const [repostEvent, setRepostEvent] = useState<NDKEvent | undefined>() const [repostEvent, setRepostEvent] = useState<NDKEvent | undefined>()
const [repostProfile, setRepostProfile] = useState<UserProfile | undefined>() const [repostProfile, setRepostProfile] = useState<UserProfile | undefined>()
const noteEvent = repostEvent ?? ndkEvent const noteEvent = repostEvent ?? ndkEvent
@ -48,10 +64,26 @@ export const Note = ({ ndkEvent }: NoteProps) => {
ndkEvent.author.fetchProfile().then((res) => setEventProfile(res)) ndkEvent.author.fetchProfile().then((res) => setEventProfile(res))
if (isRepost) { if (isRepost) {
try {
const parsedEvent = JSON.parse(ndkEvent.content) const parsedEvent = JSON.parse(ndkEvent.content)
const ndkRepostEvent = new NDKEvent(ndk, parsedEvent) const ndkRepostEvent = new NDKEvent(ndk, parsedEvent)
setRepostEvent(ndkRepostEvent) setRepostEvent(ndkRepostEvent)
ndkRepostEvent.author.fetchProfile().then((res) => setRepostProfile(res)) ndkRepostEvent.author
.fetchProfile()
.then((res) => setRepostProfile(res))
} catch (error) {
if (error instanceof SyntaxError) {
log(
true,
LogType.Error,
'Event content malformed',
error,
ndkEvent.content
)
} else {
log(true, LogType.Error, error)
}
}
} }
const repostFilter: NDKFilter = { const repostFilter: NDKFilter = {
@ -109,8 +141,6 @@ export const Note = ({ ndkEvent }: NoteProps) => {
const baseUrl = appRoutes.feed + '/' const baseUrl = appRoutes.feed + '/'
// Did user already repost this
// Show who reposted the note // Show who reposted the note
const reposterVisual = const reposterVisual =
repostEvent && reposterRoute ? ( repostEvent && reposterRoute ? (
@ -138,13 +168,26 @@ export const Note = ({ ndkEvent }: NoteProps) => {
) : null ) : null
const handleRepost = async (confirm: boolean) => { const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false) setShowRepostPopup(false)
// Cancel if not confirmed // Cancel if not confirmed
if (!confirm) return if (!confirm) return
const repostNdkEvent = await ndkEvent.repost(false) const repostNdkEvent = await ndkEvent.repost(false)
await repostNdkEvent.sign() const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
note1: ndkEvent.encode(),
data: rawEvent
}),
{
method: 'post',
encType: 'application/json'
}
)
} }
// Is this user's repost? // Is this user's repost?
@ -178,19 +221,31 @@ export const Note = ({ ndkEvent }: NoteProps) => {
</div> </div>
{noteEvent.created_at && ( {noteEvent.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'> <div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'> <Link
to={baseUrl + noteEvent.encode()}
className='IBMSMSMBSSCL_CADTime'
>
{formatDate(noteEvent.created_at * 1000, 'hh:mm aa')}{' '} {formatDate(noteEvent.created_at * 1000, 'hh:mm aa')}{' '}
</a> </Link>
<a className='IBMSMSMBSSCL_CADDate'> <Link
to={baseUrl + noteEvent.encode()}
className='IBMSMSMBSSCL_CADDate'
>
{formatDate(noteEvent.created_at * 1000, 'dd/MM/yyyy')} {formatDate(noteEvent.created_at * 1000, 'dd/MM/yyyy')}
</a> </Link>
</div> </div>
)} )}
</div> </div>
</div> </div>
<NsfwCommentWrapper
id={ndkEvent.id}
isNsfw={isNsfw}
hideNsfwActive={NSFWFilter.Hide_NSFW === filterOptions.nsfw}
>
<div className='IBMSMSMBSSCL_CommentBottom'> <div className='IBMSMSMBSSCL_CommentBottom'>
<CommentContent content={noteEvent.content} /> <CommentContent content={noteEvent.content} isNsfw={isNsfw} />
</div> </div>
</NsfwCommentWrapper>
<div className='IBMSMSMBSSCL_CommentActions'> <div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'> <div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...noteEvent.rawEvent()} /> <Reactions {...noteEvent.rawEvent()} />

View File

@ -46,7 +46,7 @@ export const NoteQuoteRepostPopup = ({
Quote repost this? Quote repost this?
</label> </label>
<NoteSubmit <NoteSubmit
initialContent={`\n\n:${content}`} initialContent={`\n\n${content}`}
handleClose={handleClose} handleClose={handleClose}
/> />
</div> </div>

View File

@ -6,6 +6,8 @@ import { Fragment } from 'react/jsx-runtime'
import { BlogPreview } from './internal/BlogPreview' import { BlogPreview } from './internal/BlogPreview'
import { ModPreview } from './internal/ModPreview' import { ModPreview } from './internal/ModPreview'
import { NoteWrapper } from './internal/NoteWrapper' import { NoteWrapper } from './internal/NoteWrapper'
import { NIP05_REGEX } from 'nostr-tools/nip05'
import { isValidImageUrl, isValidUrl, isValidVideoUrl } from 'utils'
interface NoteRenderProps { interface NoteRenderProps {
content: string content: string
@ -16,20 +18,36 @@ const nostrMention =
/(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi /(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi
const nostrEntity = const nostrEntity =
/(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi /(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi
const nostrNip5Mention = /(?:nostr:|@)([^\s]{1,64}@[^\s]+\.[^\s]{2,})/gi
export const NoteRender = ({ content }: NoteRenderProps) => { export const NoteRender = ({ content }: NoteRenderProps) => {
const _content = useMemo(() => { const _content = useMemo(() => {
if (!content) return if (!content) return
const parts = content.split( const parts = content.split(
new RegExp(`(${link.source})|(${nostrMention.source})`, 'gui') new RegExp(
`(${link.source})|(${nostrMention.source})|${nostrNip5Mention.source}`,
'gui'
)
) )
const _parts = parts.map((part, index) => { const _parts = parts.map((part, index) => {
if (link.test(part)) { if (link.test(part)) {
const [href] = part.match(link) || [] const [href] = part.match(link) || []
if (href && isValidUrl(href)) {
if (isValidImageUrl(href)) {
// Image
return <img className='imgFeedRender' src={href} alt='' />
} else if (isValidVideoUrl(href)) {
// Video
return <video className='videoFeedRender' src={href} controls />
}
}
// Link
return ( return (
<a key={index} href={href}> <a key={index} target='_blank' href={href}>
{href} {href}
</a> </a>
) )
@ -63,6 +81,9 @@ export const NoteRender = ({ content }: NoteRenderProps) => {
} catch (error) { } catch (error) {
return part return part
} }
} else if (NIP05_REGEX.test(part)) {
const [nip05] = part.match(NIP05_REGEX) || []
return <ProfileLink key={index} nip05={nip05} />
} else { } else {
return part return part
} }

View File

@ -7,7 +7,7 @@ import { CommentContent } from 'components/comment/CommentContent'
import { getProfilePageRoute } from 'routes' import { getProfilePageRoute } from 'routes'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { UserProfile } from 'types' import { UserProfile } from 'types'
import { hexToNpub } from 'utils' import { hexToNpub, log, LogType } from 'utils'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
interface NoteRepostProps { interface NoteRepostProps {
@ -28,8 +28,16 @@ export const NoteRepostPopup = ({
useDidMount(async () => { useDidMount(async () => {
const repost = await ndkEvent.repost(false) const repost = await ndkEvent.repost(false)
setContent(JSON.parse(repost.content).content)
ndkEvent.author.fetchProfile().then((res) => setProfile(res)) ndkEvent.author.fetchProfile().then((res) => setProfile(res))
try {
setContent(JSON.parse(repost.content).content)
} catch (error) {
if (error instanceof SyntaxError) {
log(true, LogType.Error, 'Repost event content malformed', error)
} else {
log(true, LogType.Error, error)
}
}
}) })
const profileRoute = getProfilePageRoute( const profileRoute = getProfilePageRoute(

View File

@ -1,12 +1,20 @@
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
import { FALLBACK_PROFILE_IMAGE } from '../../constants' import { FALLBACK_PROFILE_IMAGE } from '../../constants'
import { useAppSelector } from 'hooks' import { useAppSelector, useLocalCache } from 'hooks'
import { useProfile } from 'hooks/useProfile' import { useProfile } from 'hooks/useProfile'
import { Navigate, useNavigation, useSubmit } from 'react-router-dom' import {
Navigate,
useActionData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { appRoutes } from 'routes' import { appRoutes } from 'routes'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { adjustTextareaHeight } from 'utils' import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils'
import { NotePreview } from './NotePreview' import { NotePreview } from './NotePreview'
import { NoteSubmitActionResult, NoteSubmitForm } from 'types'
import { InputError } from 'components/Inputs/Error'
import { AlertPopup } from 'components/AlertPopup'
interface NoteSubmitProps { interface NoteSubmitProps {
initialContent?: string | undefined initialContent?: string | undefined
@ -22,32 +30,55 @@ export const NoteSubmit = ({
const profile = useProfile(userState.user?.pubkey as string | undefined, { const profile = useProfile(userState.user?.pubkey as string | undefined, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
}) })
const [content, setContent] = useState(initialContent ?? '') const [cache, setCache] = useLocalCache<NoteSubmitForm>(NOTE_DRAFT_CACHE_KEY)
const [nsfw, setNsfw] = useState(false) const [content, setContent] = useState(initialContent ?? cache?.content ?? '')
const [nsfw, setNsfw] = useState(cache?.nsfw ?? false)
const [showPreview, setShowPreview] = useState(!!initialContent) const [showPreview, setShowPreview] = useState(!!initialContent)
const image = profile?.image || FALLBACK_PROFILE_IMAGE const image = useMemo(
() => profile?.image || FALLBACK_PROFILE_IMAGE,
[profile?.image]
)
const ref = useRef<HTMLTextAreaElement>(null) const ref = useRef<HTMLTextAreaElement>(null)
const submit = useSubmit() const submit = useSubmit()
const actionData = useActionData() as NoteSubmitActionResult
const formErrors = useMemo(
() =>
actionData?.type === 'validation' ? actionData.formErrors : undefined,
[actionData]
)
useEffect(() => { useEffect(() => {
if (ref.current && !!initialContent) { if (ref.current && (!!initialContent || !!cache?.content)) {
adjustTextareaHeight(ref.current) adjustTextareaHeight(ref.current)
ref.current.focus() ref.current.focus()
} }
}, [initialContent]) }, [cache?.content, initialContent])
const handleContentChange = ( useEffect(() => {
event: React.ChangeEvent<HTMLTextAreaElement> setCache({
) => {
setContent(event.currentTarget.value)
adjustTextareaHeight(event.currentTarget)
}
const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formSubmit = {
content, content,
nsfw nsfw
})
}, [content, nsfw, setCache])
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
useEffect(() => {
const isTimeout = actionData?.type === 'timeout'
setShowTryAgainPopup(isTimeout)
if (isTimeout && actionData.action.intent === 'submit') {
setContent(actionData.action.data.content)
setNsfw(actionData.action.data.nsfw)
}
}, [actionData])
const handleFormSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault()
const formSubmit = {
intent: 'submit',
data: {
content,
nsfw
}
} }
// Reset form // Reset form
@ -56,10 +87,35 @@ export const NoteSubmit = ({
submit(JSON.stringify(formSubmit), { submit(JSON.stringify(formSubmit), {
method: 'post', method: 'post',
encType: 'application/json' encType: 'application/json',
action: appRoutes.feed
}) })
typeof handleClose === 'function' && handleClose() typeof handleClose === 'function' && handleClose()
},
[content, handleClose, nsfw, submit]
)
const handleTryAgainConfirm = useCallback(
(confirm: boolean) => {
setShowTryAgainPopup(false)
// Cancel if not confirmed
if (!confirm) return
// Reset form
setContent('')
setNsfw(false)
handleFormSubmit()
},
[handleFormSubmit]
)
const handleContentChange = (
event: React.ChangeEvent<HTMLTextAreaElement>
) => {
setContent(event.currentTarget.value)
adjustTextareaHeight(event.currentTarget)
} }
const handlePreviewToggle = () => { const handlePreviewToggle = () => {
@ -138,13 +194,24 @@ export const NoteSubmit = ({
className='btn btnMain' className='btn btnMain'
type='submit' type='submit'
style={{ padding: '5px 20px', borderRadius: '8px' }} style={{ padding: '5px 20px', borderRadius: '8px' }}
disabled={navigation.state !== 'idle'} disabled={navigation.state !== 'idle' || !content.length}
> >
{navigation.state === 'idle' ? 'Post' : 'Posting...'} {navigation.state === 'submitting' ? 'Posting...' : 'Post'}
</button> </button>
</div> </div>
</div> </div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{showPreview && <NotePreview content={content} />} {showPreview && <NotePreview content={content} />}
{showTryAgainPopup && (
<AlertPopup
handleConfirm={handleTryAgainConfirm}
handleClose={() => setShowTryAgainPopup(false)}
header={'Post'}
label={`Posting timed out. Do you want to try again?`}
/>
)}
</div> </div>
</form> </form>
</> </>

View File

@ -32,6 +32,8 @@ export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => {
if (!note) return <Dots /> if (!note) return <Dots />
const baseUrl = appRoutes.feed + '/'
return ( return (
<div className='IBMSMSMBSSCL_CommentQP'> <div className='IBMSMSMBSSCL_CommentQP'>
<div className='IBMSMSMBSSCL_Comment'> <div className='IBMSMSMBSSCL_Comment'>
@ -58,12 +60,18 @@ export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => {
</div> </div>
{note.created_at && ( {note.created_at && (
<div className='IBMSMSMBSSCL_CommentActionsDetails'> <div className='IBMSMSMBSSCL_CommentActionsDetails'>
<a className='IBMSMSMBSSCL_CADTime'> <Link
to={baseUrl + noteEntity}
className='IBMSMSMBSSCL_CADTime'
>
{formatDate(note.created_at * 1000, 'hh:mm aa')}{' '} {formatDate(note.created_at * 1000, 'hh:mm aa')}{' '}
</a> </Link>
<a className='IBMSMSMBSSCL_CADDate'> <Link
to={baseUrl + noteEntity}
className='IBMSMSMBSSCL_CADDate'
>
{formatDate(note.created_at * 1000, 'dd/MM/yyyy')} {formatDate(note.created_at * 1000, 'dd/MM/yyyy')}
</a> </Link>
</div> </div>
)} )}
</div> </div>

View File

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

View File

@ -1,7 +1,7 @@
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts' import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { import {
@ -27,7 +27,11 @@ import {
import { LoadingSpinner } from './LoadingSpinner' import { LoadingSpinner } from './LoadingSpinner'
import { ZapPopUp } from './Zap' import { ZapPopUp } from './Zap'
import placeholder from '../assets/img/DEGMods Placeholder Img.png' import placeholder from '../assets/img/DEGMods Placeholder Img.png'
import { NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import {
NDKEvent,
NDKSubscriptionCacheUsage,
NDKUser
} from '@nostr-dev-kit/ndk'
import { useProfile } from 'hooks/useProfile' import { useProfile } from 'hooks/useProfile'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -577,15 +581,31 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
) )
} }
export const ProfileLink = ({ pubkey }: Props) => { type ProfileLinkProps = {
let hexPubkey: string | null = null pubkey?: string
let profileRoute: string | undefined = appRoutes.home nip05?: string
}
export const ProfileLink = ({ pubkey, nip05 }: ProfileLinkProps) => {
const { ndk } = useNDKContext()
const [hexPubkey, setHexPubkey] = useState<string>()
const profile = useProfile(hexPubkey, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
useEffect(() => {
if (pubkey) {
setHexPubkey(npubToHex(pubkey)!)
} else if (nip05) {
NDKUser.fromNip05(nip05, ndk).then((user) => {
if (user?.pubkey) {
setHexPubkey(npubToHex(user.pubkey)!)
}
})
}
}, [pubkey, nip05, ndk])
const profileRoute = useMemo(() => {
let nprofile: string | undefined let nprofile: string | undefined
const npub = hexToNpub(pubkey)
try { try {
hexPubkey = npubToHex(pubkey)
if (hexPubkey) { if (hexPubkey) {
nprofile = hexPubkey nprofile = hexPubkey
? nip19.nprofileEncode({ ? nip19.nprofileEncode({
@ -598,11 +618,14 @@ export const ProfileLink = ({ pubkey }: Props) => {
log(true, LogType.Error, 'Failed to encode profile.', error) log(true, LogType.Error, 'Failed to encode profile.', error)
} }
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home return nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
const profile = useProfile(hexPubkey!, { }, [hexPubkey])
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
const displayName = useMemo(() => {
const npub = hexPubkey ? hexToNpub(hexPubkey) : ''
const displayName = profile?.displayName || profile?.name || truncate(npub) const displayName = profile?.displayName || profile?.name || truncate(npub)
return displayName
}, [hexPubkey, profile?.displayName, profile?.name])
return <Link to={profileRoute}>@{displayName}</Link> return <Link to={profileRoute}>@{displayName}</Link>
} }

View File

@ -1,8 +1,19 @@
import { NDKKind } from '@nostr-dev-kit/ndk' import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { useDidMount, useNDKContext } from 'hooks' import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
import { useState } from 'react' import { useState } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom' import {
useParams,
useLocation,
Link,
useSubmit,
useNavigation
} from 'react-router-dom'
import { import {
getModPageRoute, getModPageRoute,
getBlogPageRoute, getBlogPageRoute,
@ -15,6 +26,8 @@ import { Reactions } from './Reactions'
import { Zap } from './Zap' import { Zap } from './Zap'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { CommentContent } from './CommentContent' import { CommentContent } from './CommentContent'
import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup'
import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup'
interface CommentProps { interface CommentProps {
comment: CommentEvent comment: CommentEvent
@ -38,6 +51,16 @@ export const Comment = ({ comment }: CommentProps) => {
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([]) const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
const [profile, setProfile] = useState<UserProfile>() const [profile, setProfile] = useState<UserProfile>()
const submit = useSubmit()
const navigation = useNavigation()
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
const [hasReposted, setHasReposted] = useState(false)
const [hasQuoted, setHasQuoted] = useState(false)
const [showRepostPopup, setShowRepostPopup] = useState(false)
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
useDidMount(() => { useDidMount(() => {
comment.event.author.fetchProfile().then((res) => setProfile(res)) comment.event.author.fetchProfile().then((res) => setProfile(res))
ndk ndk
@ -52,7 +75,65 @@ export const Comment = ({ comment }: CommentProps) => {
})) }))
) )
}) })
const repostFilter: NDKFilter = {
kinds: [NDKKind.Repost],
'#e': [comment.event.id]
}
const quoteFilter: NDKFilter = {
kinds: [NDKKind.Text],
'#q': [comment.event.id]
}
ndk
.fetchEvents([repostFilter, quoteFilter], {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
}) })
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
if (ndkEventSet.size) {
const quoteRepostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Text
)
userPubkey &&
setHasQuoted(
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
)
setQuoteRepostEvents(quoteRepostEvents)
const repostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Repost
)
userPubkey &&
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
setRepostEvents(repostEvents)
}
})
})
const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false)
// Cancel if not confirmed
if (!confirm) return
const repostNdkEvent = await comment.event.repost(false)
const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
note1: comment.event.encode(),
data: rawEvent
}),
{
method: 'post',
encType: 'application/json',
action: appRoutes.feed
}
)
}
const profileRoute = getProfilePageRoute( const profileRoute = getProfilePageRoute(
nip19.nprofileEncode({ nip19.nprofileEncode({
@ -107,9 +188,54 @@ export const Comment = ({ comment }: CommentProps) => {
<div className='IBMSMSMBSSCL_CommentActions'> <div className='IBMSMSMBSSCL_CommentActions'>
<div className='IBMSMSMBSSCL_CommentActionsInside'> <div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...comment.event.rawEvent()} /> <Reactions {...comment.event.rawEvent()} />
{/* <div
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost' {comment.event.kind === NDKKind.Text && (
style={{ cursor: 'not-allowed' }} <>
{/* Quote Repost, Kind 1 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
navigation.state === 'idle'
? () => setShowQuoteRepostPopup(true)
: undefined
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{quoteRepostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showQuoteRepostPopup && (
<NoteQuoteRepostPopup
ndkEvent={comment.event}
handleClose={() => setShowQuoteRepostPopup(false)}
/>
)}
{/* Repost, Kind 6 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
navigation.state === 'idle'
? () => setShowRepostPopup(true)
: undefined
}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -121,11 +247,23 @@ export const Comment = ({ comment }: CommentProps) => {
> >
<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> <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> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p> <p className='IBMSMSMBSSCL_CAElementText'>
{repostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
</div> */} </div>
{showRepostPopup && (
<NoteRepostPopup
ndkEvent={comment.event}
handleConfirm={handleRepost}
handleClose={() => setShowRepostPopup(false)}
/>
)}
</>
)}
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && ( {typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
<Zap {...comment.event.rawEvent()} /> <Zap {...comment.event.rawEvent()} />
)} )}

View File

@ -3,9 +3,13 @@ import { useTextLimit } from 'hooks'
interface CommentContentProps { interface CommentContentProps {
content: string content: string
isNsfw?: boolean
} }
export const CommentContent = ({ content }: CommentContentProps) => { export const CommentContent = ({
content,
isNsfw = false
}: CommentContentProps) => {
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content) const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
return ( return (
@ -18,12 +22,17 @@ export const CommentContent = ({ content }: CommentContentProps) => {
<p>Hide full post</p> <p>Hide full post</p>
</div> </div>
)} )}
<p className='IBMSMSMBSSCL_CBText'> <div className='IBMSMSMBSSCL_CBText'>
<NoteRender content={text} /> <NoteRender content={text} />
</p> </div>
{isTextOverflowing && ( {isTextOverflowing && !isExpanded && (
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}> <div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
<p>{isExpanded ? 'Hide' : 'View'} full post</p> <p>View full post</p>
</div>
)}
{isNsfw && (
<div className='IBMSMSMBSSCL_CommentNSWFTag'>
<p>NSFW</p>
</div> </div>
)} )}
</> </>

View File

@ -1,5 +1,11 @@
import { formatDate } from 'date-fns' import { formatDate } from 'date-fns'
import { useBodyScrollDisable, useNDKContext, useReplies } from 'hooks' import {
useAppSelector,
useBodyScrollDisable,
useDidMount,
useNDKContext,
useReplies
} from 'hooks'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { import {
@ -7,7 +13,9 @@ import {
useLoaderData, useLoaderData,
useLocation, useLocation,
useNavigate, useNavigate,
useParams useNavigation,
useParams,
useSubmit
} from 'react-router-dom' } from 'react-router-dom'
import { import {
appRoutes, appRoutes,
@ -24,8 +32,21 @@ import { Comment } from './Comment'
import { useComments } from 'hooks/useComments' import { useComments } from 'hooks/useComments'
import { CommentContent } from './CommentContent' import { CommentContent } from './CommentContent'
import { Dots } from 'components/Spinner' import { Dots } from 'components/Spinner'
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup'
import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup'
import _ from 'lodash'
export const CommentsPopup = () => { interface CommentsPopupProps {
title: string
}
export const CommentsPopup = ({ title }: CommentsPopupProps) => {
const { naddr } = useParams() const { naddr } = useParams()
const location = useLocation() const location = useLocation()
const { ndk } = useNDKContext() const { ndk } = useNDKContext()
@ -43,12 +64,14 @@ export const CommentsPopup = () => {
? `${appRoutes.feed}/` ? `${appRoutes.feed}/`
: undefined : undefined
const { event } = useLoaderData() as CommentsLoaderResult const { event } = useLoaderData() as CommentsLoaderResult
const eTags = event.getMatchingTags('e')
const lastETag = _.last(eTags)
const { const {
size, size,
parent: replyEvent, parent: replyEvent,
isComplete, isComplete,
root: rootEvent root: rootEvent
} = useReplies(event.tagValue('e')) } = useReplies(lastETag?.[1])
const isRoot = event.tagValue('a') === event.tagValue('A') const isRoot = event.tagValue('a') === event.tagValue('A')
const [profile, setProfile] = useState<UserProfile>() const [profile, setProfile] = useState<UserProfile>()
const { commentEvents, setCommentEvents } = useComments( const { commentEvents, setCommentEvents } = useComments(
@ -112,6 +135,80 @@ export const CommentsPopup = () => {
setIsSubmitting(false) setIsSubmitting(false)
} }
const submit = useSubmit()
const navigation = useNavigation()
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
const [hasReposted, setHasReposted] = useState(false)
const [hasQuoted, setHasQuoted] = useState(false)
const [showRepostPopup, setShowRepostPopup] = useState(false)
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined
useDidMount(() => {
const repostFilter: NDKFilter = {
kinds: [NDKKind.Repost],
'#e': [event.id]
}
const quoteFilter: NDKFilter = {
kinds: [NDKKind.Text],
'#q': [event.id]
}
ndk
.fetchEvents([repostFilter, quoteFilter], {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
if (ndkEventSet.size) {
const quoteRepostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Text
)
userPubkey &&
setHasQuoted(
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
)
setQuoteRepostEvents(quoteRepostEvents)
const repostEvents = ndkEvents.filter(
(n) => n.kind === NDKKind.Repost
)
userPubkey &&
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
setRepostEvents(repostEvents)
}
})
.finally(() => {
setIsLoading(false)
})
})
const handleRepost = async (confirm: boolean) => {
if (navigation.state !== 'idle') return
setShowRepostPopup(false)
// Cancel if not confirmed
if (!confirm) return
const repostNdkEvent = await event.repost(false)
const rawEvent = repostNdkEvent.rawEvent()
submit(
JSON.stringify({
intent: 'repost',
note1: event.encode(),
data: rawEvent
}),
{
method: 'post',
encType: 'application/json',
action: appRoutes.feed
}
)
}
return ( return (
<div className='popUpMain'> <div className='popUpMain'>
<div className='ContainerMain'> <div className='ContainerMain'>
@ -119,7 +216,7 @@ export const CommentsPopup = () => {
<div className='popUpMainCard'> <div className='popUpMainCard'>
<div className='popUpMainCardTop'> <div className='popUpMainCardTop'>
<div className='popUpMainCardTopInfo'> <div className='popUpMainCardTopInfo'>
<h3>Comment replies</h3> <h3>{title}</h3>
</div> </div>
<div <div
@ -227,9 +324,53 @@ export const CommentsPopup = () => {
<div className='IBMSMSMBSSCL_CommentActionsInside'> <div className='IBMSMSMBSSCL_CommentActionsInside'>
<Reactions {...event.rawEvent()} /> <Reactions {...event.rawEvent()} />
{/* <div {event.kind === NDKKind.Text && (
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost' <>
style={{ cursor: 'not-allowed' }} {/* Quote Repost, Kind 1 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading
? undefined
: () => setShowQuoteRepostPopup(true)
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
className='IBMSMSMBSSCL_CAElementIcon'
>
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
</svg>
<p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : quoteRepostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div>
</div>
{showQuoteRepostPopup && (
<NoteQuoteRepostPopup
ndkEvent={event}
handleClose={() => setShowQuoteRepostPopup(false)}
/>
)}
{/* Repost, Kind 6 */}
<div
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
}`}
onClick={
isLoading || hasReposted
? undefined
: () => setShowRepostPopup(true)
}
> >
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
@ -241,11 +382,22 @@ export const CommentsPopup = () => {
> >
<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> <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> </svg>
<p className='IBMSMSMBSSCL_CAElementText'>0</p> <p className='IBMSMSMBSSCL_CAElementText'>
{isLoading ? <Dots /> : repostEvents.length}
</p>
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'> <div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
<div className='IBMSMSMBSSCL_CAElementLoad'></div> <div className='IBMSMSMBSSCL_CAElementLoad'></div>
</div> </div>
</div> */} </div>
{showRepostPopup && (
<NoteRepostPopup
ndkEvent={event}
handleConfirm={handleRepost}
handleClose={() => setShowRepostPopup(false)}
/>
)}
</>
)}
{typeof profile?.lud16 !== 'undefined' && {typeof profile?.lud16 !== 'undefined' &&
profile.lud16 !== '' && <Zap {...event.rawEvent()} />} profile.lud16 !== '' && <Zap {...event.rawEvent()} />}

View File

@ -82,11 +82,49 @@ export const useComments = (
) )
subscription.on('event', (ndkEvent) => { subscription.on('event', (ndkEvent) => {
const eTags = ndkEvent.getMatchingTags('e')
const aTags = ndkEvent.getMatchingTags('a')
// This event is not a reply to, nor does it refer to any other event
if (!aTags.length && !eTags.length) return
setCommentEvents((prev) => { setCommentEvents((prev) => {
if (prev.find((e) => e.event.id === ndkEvent.id)) { if (ndkEvent.kind === NDKKind.Text) {
// Resolve comments with markers and positional "e" tags
// https://github.com/nostr-protocol/nips/blob/master/10.md
const root = ndkEvent.getMatchingTags('e', 'root')
const replies = ndkEvent.getMatchingTags('e', 'reply')
// This event has reply markers but does not match eTag
if (replies.length && !replies.some((e) => eTag === e[1])) {
return [...prev] return [...prev]
} }
// This event has a single #e tag reference
// Checks single marked event (root) and a single positional "e" tags
// Allow if either old kind 1 reply to addressable or matches eTag
if (eTags.length === 1 && !(aTag || eTag === eTags[0][1])) {
return [...prev]
}
// Position "e" tags (no markets)
// Multiple e tags, checks the last "e" tag
// Last "e" tag does not match eTag
if (
root.length + replies.length === 0 &&
eTags.length > 1 &&
eTags[eTags.length - 1][1] !== eTag
) {
return [...prev]
}
}
// Event is already included
if (prev.find((comment) => comment.event.id === ndkEvent.id)) {
return [...prev]
}
// Event is a direct reply
return [{ event: ndkEvent }, ...prev] return [{ event: ndkEvent }, ...prev]
}) })
}) })

View File

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

View File

@ -1,40 +1,48 @@
import { useLoaderData } from 'react-router-dom' import { useLoaderData } from 'react-router-dom'
import { FeedPageLoaderResult } from './loader' import { FeedPageLoaderResult } from './loader'
import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks' import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks'
import { FilterOptions, NSFWFilter } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { import {
NDKEvent, NDKEvent,
NDKFilter, NDKFilter,
NDKKind, NDKKind,
NDKSubscription,
NDKSubscriptionCacheUsage NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { NoteSubmit } from 'components/Notes/NoteSubmit' import { NoteSubmit } from 'components/Notes/NoteSubmit'
import { Note } from 'components/Notes/Note' import { Note } from 'components/Notes/Note'
import { FeedPostsFilter, RepostFilter } from 'types'
import { DEFAULT_FILTER_OPTIONS } from 'utils'
import { Dots } from 'components/Spinner'
export const FeedTabPosts = () => { export const FeedTabPosts = () => {
const SHOWING_STEP = 20 const SHOWING_STEP = 20
const { followList } = useLoaderData() as FeedPageLoaderResult const { followList } = useLoaderData() as FeedPageLoaderResult
const userState = useAppSelector((state) => state.user) const userState = useAppSelector((state) => state.user)
const userPubkey = userState.user?.pubkey as string | undefined const userPubkey = userState.user?.pubkey as string | undefined
const filterKey = 'filter-feed-2'
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
...DEFAULT_FILTER_OPTIONS
})
const { ndk } = useNDKContext() const { ndk } = useNDKContext()
const [notes, setNotes] = useState<NDKEvent[]>([]) const [notes, setNotes] = useState<NDKEvent[]>([])
const [discoveredNotes, setDiscoveredNotes] = useState<NDKEvent[]>([])
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true)
const [showing, setShowing] = useState(SHOWING_STEP) const [showing, setShowing] = useState(SHOWING_STEP)
const filterKey = 'filter-feed-2'
const [filterOptions] = useLocalStorage<FeedPostsFilter>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
useEffect(() => { useEffect(() => {
if (!userPubkey) return if (!userPubkey) return
setIsFetching(true) setIsFetching(true)
setIsLoadMoreVisible(true) setIsLoadMoreVisible(true)
let sub: NDKSubscription
const filter: NDKFilter = { const filter: NDKFilter = {
authors: [...followList, userPubkey], authors: [...followList, userPubkey],
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
@ -47,60 +55,98 @@ export const FeedTabPosts = () => {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
}) })
.then((ndkEventSet) => { .then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet) const ndkEvents = Array.from(ndkEventSet).sort(
(a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)
)
setNotes(ndkEvents) setNotes(ndkEvents)
const firstNote = ndkEvents[0]
const filter: NDKFilter = {
authors: [...followList, userPubkey],
kinds: [NDKKind.Text, NDKKind.Repost],
since: firstNote.created_at
}
sub = ndk.subscribe(filter, {
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
sub.on('event', (ndkEvent) => {
setDiscoveredNotes((prev) => {
// Skip existing
if (
prev.find(
(e) =>
e.id === ndkEvent.id ||
ndkEvents.findIndex((n) => n.id === ndkEvent.id) === -1
)
) {
return [...prev]
}
return [...prev, ndkEvent]
})
})
sub.start()
}) })
.finally(() => { .finally(() => {
setIsFetching(false) setIsFetching(false)
}) })
return () => {
if (sub) sub.stop()
}
}, [followList, ndk, userPubkey]) }, [followList, ndk, userPubkey])
const filteredNotes = useMemo(() => { const filteredNotes = useMemo(() => {
let _notes = notes || [] let _notes = notes || []
// Filter nsfw (Hide_NSFW option)
_notes = _notes.filter(
(n) =>
!(
filterOptions.nsfw === NSFWFilter.Hide_NSFW &&
n.tagValue('L') === 'content-warning'
)
)
// Filter source // Filter source
_notes = _notes.filter( // TODO: Enable source/client filter
(n) => // _notes = _notes.filter(
!( // (n) =>
filterOptions.source === window.location.host && // !(
n // filterOptions.source === window.location.host &&
.getMatchingTags('l') // n
.some((l) => l[1] === window.location.host && l[2] === 'source') // .getMatchingTags('l')
) // .some((l) => l[1] === window.location.host && l[2] === 'source')
) // )
// )
// Filter reply events _notes = _notes.filter((n) => {
_notes = _notes.filter((n) => n.getMatchingTags('e').length === 0) if (n.kind === NDKKind.Text) {
// Filter out the replies (Kind 1 events with e tags are replies to other kind 1 events)
return n.getMatchingTags('e').length === 0
}
// Filter repost events if the option is set to hide reposts
return !(
n.kind === NDKKind.Repost &&
filterOptions.repost === RepostFilter.Hide_Repost
)
})
_notes = _notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) _notes = _notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
showing > 0 && _notes.splice(showing) showing > 0 && _notes.splice(showing)
return _notes return _notes
}, [filterOptions.nsfw, filterOptions.source, notes, showing]) }, [filterOptions.repost, notes, showing])
const newNotes = useMemo(
() => discoveredNotes.filter((d) => !notes.some((n) => n.id === d.id)),
[discoveredNotes, notes]
)
if (!userPubkey) return null if (!userPubkey) return null
const handleLoadMore = () => { const handleLoadMore = () => {
const LOAD_MORE_STEP = SHOWING_STEP * 2 const LOAD_MORE_STEP = SHOWING_STEP * 2
setShowing((prev) => prev + SHOWING_STEP) setShowing((prev) => prev + SHOWING_STEP)
const lastNote = filteredNotes[filteredNotes.length - 1] const lastNote = notes[notes.length - 1]
const filter: NDKFilter = { const filter: NDKFilter = {
authors: [...followList, userPubkey], authors: [...followList, userPubkey],
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
limit: LOAD_MORE_STEP limit: LOAD_MORE_STEP,
until: lastNote.created_at
} }
filter.until = lastNote.created_at
setIsFetching(true) setIsFetching(true)
ndk ndk
.fetchEvents(filter, { .fetchEvents(filter, {
@ -129,9 +175,42 @@ export const FeedTabPosts = () => {
}) })
} }
const discoveredCount = newNotes.length
const handleDiscoveredClick = () => {
// Combine newly discovred with the notes
// Skip events already in notes
setNotes((prev) => {
return [...newNotes, ...prev]
})
// Increase showing by the discovered count
setShowing((prev) => prev + discoveredCount)
setDiscoveredNotes([])
}
return ( return (
<> <>
<NoteSubmit /> <NoteSubmit />
<div>
<button
className='btnMain'
type='button'
style={{ width: '100%' }}
onClick={discoveredCount ? handleDiscoveredClick : undefined}
>
<span>
{isFetching ? (
<>
Discovering notes
<Dots />
</>
) : discoveredCount ? (
<>Load {discoveredCount} discovered notes</>
) : (
<>No new notes</>
)}
</span>
</button>
</div>
{isFetching && <LoadingSpinner desc='Fetching notes from relays' />} {isFetching && <LoadingSpinner desc='Fetching notes from relays' />}
{filteredNotes.length === 0 && !isFetching && ( {filteredNotes.length === 0 && !isFetching && (
<div className='IBMSMListFeedNoPosts'> <div className='IBMSMListFeedNoPosts'>

View File

@ -1,11 +1,23 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk' import NDK, { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext' import { NDKContextType } from 'contexts/NDKContext'
import { ActionFunctionArgs, redirect } from 'react-router-dom' import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { getFeedNotePageRoute } from 'routes' import { getFeedNotePageRoute } from 'routes'
import { store } from 'store' import { store } from 'store'
import { NoteSubmitForm, NoteSubmitFormErrors } from 'types' import {
import { log, LogType, now } from 'utils' NoteAction,
NoteSubmitForm,
NoteSubmitFormErrors,
TimeoutError
} from 'types'
import {
log,
LogType,
NOTE_DRAFT_CACHE_KEY,
now,
removeLocalStorageItem,
timeout
} from 'utils'
export const feedPostRouteAction = export const feedPostRouteAction =
(ndkContext: NDKContextType) => (ndkContext: NDKContextType) =>
@ -32,39 +44,36 @@ export const feedPostRouteAction =
return null return null
} }
const formSubmit = (await request.json()) as NoteSubmitForm let action: NoteAction | undefined
const formErrors = validateFormData(formSubmit)
if (Object.keys(formErrors).length) return formErrors
const content = decodeURIComponent(formSubmit.content!)
const currentTimeStamp = now()
const ndkEvent = new NDKEvent(ndkContext.ndk, {
kind: NDKKind.Text,
created_at: currentTimeStamp,
content: content,
tags: [
['L', 'source'],
['l', window.location.host, 'source']
],
pubkey: hexPubkey
})
try { try {
if (formSubmit.nsfw) ndkEvent.tags.push(['L', 'content-warning']) action = (await request.json()) as NoteAction
switch (action.intent) {
case 'submit':
return await handleActionSubmit(
ndkContext.ndk,
action.data,
hexPubkey
)
await ndkEvent.sign() case 'repost':
const note1 = ndkEvent.encode() return await handleActionRepost(
const publishedOnRelays = await ndkEvent.publish() ndkContext.ndk,
if (publishedOnRelays.size === 0) { action.data,
toast.error('Failed to publish note on any relay') action.note1
return null )
} else {
toast.success('Note published successfully') default:
return redirect(getFeedNotePageRoute(note1)) throw new Error('Unsupported feed action. Intent missing.')
} }
} catch (error) { } catch (error) {
if (action && error instanceof TimeoutError) {
log(true, LogType.Error, 'Failed to publish note. Try again initiated')
const result = {
type: 'timeout',
action
}
return result
}
log(true, LogType.Error, 'Failed to publish note', error) log(true, LogType.Error, 'Failed to publish note', error)
toast.error('Failed to publish note') toast.error('Failed to publish note')
return null return null
@ -80,3 +89,61 @@ const validateFormData = (formSubmit: NoteSubmitForm): NoteSubmitFormErrors => {
return errors return errors
} }
async function handleActionSubmit(
ndk: NDK,
data: NoteSubmitForm,
pubkey: string
) {
const formErrors = validateFormData(data)
if (Object.keys(formErrors).length)
return {
type: 'validation',
formErrors
}
const content = decodeURIComponent(data.content!)
const currentTimeStamp = now()
const ndkEvent = new NDKEvent(ndk, {
kind: NDKKind.Text,
created_at: currentTimeStamp,
content: content,
tags: [
['L', 'source'],
['l', window.location.host, 'source']
],
pubkey
})
if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning'])
await ndkEvent.sign()
const note1 = ndkEvent.encode()
const publishedOnRelays = await Promise.race([
ndkEvent.publish(),
timeout(30000)
])
if (publishedOnRelays.size === 0) {
toast.error('Failed to publish note on any relay')
return null
} else {
toast.success('Note published successfully')
removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY)
return redirect(getFeedNotePageRoute(note1))
}
}
async function handleActionRepost(ndk: NDK, data: NostrEvent, note1: string) {
const ndkEvent = new NDKEvent(ndk, data)
await ndkEvent.sign()
const publishedOnRelays = await ndkEvent.publish()
if (publishedOnRelays.size === 0) {
toast.error('Failed to publish note on any relay')
return null
} else {
toast.success('Note published successfully')
return redirect(getFeedNotePageRoute(note1))
}
}

View File

@ -1,4 +1,9 @@
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk' import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner' import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard' import { ModCard } from 'components/ModCard'
import { ModFilter } from 'components/Filters/ModsFilter' import { ModFilter } from 'components/Filters/ModsFilter'
@ -19,10 +24,12 @@ import { toast } from 'react-toastify'
import { appRoutes } from 'routes' import { appRoutes } from 'routes'
import { import {
BlogCardDetails, BlogCardDetails,
FeedPostsFilter,
FilterOptions, FilterOptions,
ModDetails, ModDetails,
ModeratedFilter, ModeratedFilter,
NSFWFilter, NSFWFilter,
RepostFilter,
SortBy, SortBy,
UserRelaysType UserRelaysType
} from 'types' } from 'types'
@ -42,6 +49,8 @@ import { CheckboxField } from 'components/Inputs'
import { ProfilePageLoaderResult } from './loader' import { ProfilePageLoaderResult } from './loader'
import { BlogCard } from 'components/BlogCard' import { BlogCard } from 'components/BlogCard'
import { BlogsFilter } from 'components/Filters/BlogsFilter' import { BlogsFilter } from 'components/Filters/BlogsFilter'
import { FeedFilter } from 'components/Filters/FeedFilter'
import { Note } from 'components/Notes/Note'
export const ProfilePage = () => { export const ProfilePage = () => {
const { const {
@ -451,7 +460,7 @@ export const ProfilePage = () => {
)} )}
{tab === 1 && <ProfileTabBlogs />} {tab === 1 && <ProfileTabBlogs />}
{tab === 2 && <>WIP</>} {tab === 2 && <ProfileTabPosts />}
</div> </div>
</div> </div>
</div> </div>
@ -870,3 +879,133 @@ const ProfileTabBlogs = () => {
</> </>
) )
} }
const ProfileTabPosts = () => {
const SHOWING_STEP = 20
const { profilePubkey } = useLoaderData() as ProfilePageLoaderResult
const { ndk } = useNDKContext()
const [notes, setNotes] = useState<NDKEvent[]>([])
const [isFetching, setIsFetching] = useState(false)
const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true)
const [showing, setShowing] = useState(SHOWING_STEP)
const filterKey = 'filter-feed-2'
const [filterOptions] = useLocalStorage<FeedPostsFilter>(
filterKey,
DEFAULT_FILTER_OPTIONS
)
useEffect(() => {
setIsFetching(true)
setIsLoadMoreVisible(true)
const filter: NDKFilter = {
authors: [profilePubkey],
kinds: [NDKKind.Text, NDKKind.Repost],
limit: 50
}
ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
setNotes(ndkEvents)
})
.finally(() => {
setIsFetching(false)
})
}, [ndk, profilePubkey])
const filteredNotes = useMemo(() => {
let _notes = notes || []
_notes = _notes.filter((n) => {
if (n.kind === NDKKind.Text) {
// Filter out the replies (Kind 1 events with e tags are replies to other kind 1 events)
return n.getMatchingTags('e').length === 0
}
// Filter repost events if the option is set to hide reposts
return !(
n.kind === NDKKind.Repost &&
filterOptions.repost === RepostFilter.Hide_Repost
)
})
_notes = _notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
showing > 0 && _notes.splice(showing)
return _notes
}, [filterOptions.repost, notes, showing])
const handleLoadMore = () => {
const LOAD_MORE_STEP = SHOWING_STEP * 2
setShowing((prev) => prev + SHOWING_STEP)
const lastNote = notes[notes.length - 1]
const filter: NDKFilter = {
authors: [profilePubkey],
kinds: [NDKKind.Text, NDKKind.Repost],
limit: LOAD_MORE_STEP
}
filter.until = lastNote.created_at
setIsFetching(true)
ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
})
.then((ndkEventSet) => {
setNotes((prevNotes) => {
const newNotes = Array.from(ndkEventSet)
const combinedNotes = [...prevNotes, ...newNotes]
const uniqueBlogs = Array.from(
new Set(combinedNotes.map((b) => b.id))
)
.map((id) => combinedNotes.find((b) => b.id === id))
.filter((b) => b !== undefined)
if (newNotes.length < LOAD_MORE_STEP) {
setIsLoadMoreVisible(false)
}
return uniqueBlogs
})
})
.finally(() => {
setIsFetching(false)
})
}
return (
<>
<FeedFilter tab={2} />
{isFetching && <LoadingSpinner desc='Fetching notes from relays' />}
{filteredNotes.length === 0 && !isFetching && (
<div className='IBMSMListFeedNoPosts'>
<p>There are no posts to show</p>
</div>
)}
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSContent'>
<div className='IBMSMSMFSSContentPosts'>
{filteredNotes.map((note) => (
<Note key={note.id} ndkEvent={note} />
))}
</div>
</div>
{!isFetching && isLoadMoreVisible && filteredNotes.length > 0 && (
<div className='IBMSMListFeedLoadMore'>
<button
className='btn btnMain IBMSMListFeedLoadMoreBtn'
type='button'
onClick={handleLoadMore}
>
Load More
</button>
</div>
)}
</>
)
}

View File

@ -109,7 +109,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
children: [ children: [
{ {
path: ':nevent', path: ':nevent',
element: <CommentsPopup />, element: <CommentsPopup title='Comment replies' />,
loader: commentsLoader(context) loader: commentsLoader(context)
} }
], ],
@ -136,7 +136,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
children: [ children: [
{ {
path: ':nevent', path: ':nevent',
element: <CommentsPopup />, element: <CommentsPopup title='Comment replies' />,
loader: commentsLoader(context) loader: commentsLoader(context)
} }
], ],
@ -209,7 +209,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
children: [ children: [
{ {
path: ':note', path: ':note',
element: <CommentsPopup />, element: <CommentsPopup title='Note and replies' />,
loader: commentsLoader(context) loader: commentsLoader(context)
} }
] ]

View File

@ -44,6 +44,7 @@
} }
.IBMSMSMBSSCL_CommentBottom { .IBMSMSMBSSCL_CommentBottom {
position: relative;
width: 100%; width: 100%;
padding: 20px; padding: 20px;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
@ -64,9 +65,6 @@
.IBMSMSMBSSCL_CBText { .IBMSMSMBSSCL_CBText {
white-space: pre-line; white-space: pre-line;
display: flex;
flex-direction: column;
grid-gap: 5px;
} }
.IBMSMSMBSSCL_CBTextStatus { .IBMSMSMBSSCL_CBTextStatus {

View File

@ -40,3 +40,5 @@ export interface FilterOptions {
wot: WOTFilterOptions wot: WOTFilterOptions
repost: RepostFilter repost: RepostFilter
} }
export type FeedPostsFilter = Pick<FilterOptions, 'nsfw' | 'repost'>

View File

@ -1,6 +1,29 @@
import { NostrEvent } from '@nostr-dev-kit/ndk'
export interface NoteSubmitForm { export interface NoteSubmitForm {
content: string content: string
nsfw: boolean nsfw: boolean
} }
export interface NoteSubmitFormErrors extends Partial<NoteSubmitForm> {} export interface NoteSubmitFormErrors extends Partial<NoteSubmitForm> {}
export type NoteAction =
| {
intent: 'submit'
data: NoteSubmitForm
}
| {
intent: 'repost'
note1: string
data: NostrEvent
}
export type NoteSubmitActionResult =
| {
type: 'timeout'
action: NoteAction
}
| {
type: 'validation'
formErrors: NoteSubmitFormErrors
}

View File

@ -104,3 +104,5 @@ export function handleCommentSubmit(
} }
} }
} }
export const NOTE_DRAFT_CACHE_KEY = 'draft-note'

View File

@ -60,6 +60,11 @@ export const isValidImageUrl = (url: string) => {
return regex.test(url) return regex.test(url)
} }
export const isValidVideoUrl = (url: string) => {
const regex = /\.(mp4|mkv|webm|mov)$/
return regex.test(url)
}
export const isReachable = async (url: string) => { export const isReachable = async (url: string) => {
try { try {
const response = await fetch(url, { method: 'HEAD' }) const response = await fetch(url, { method: 'HEAD' })