feat(notes): notes feed, render, preview, submit
This commit is contained in:
parent
74880acadd
commit
7c3bf7d76a
317
src/components/Notes/Note.tsx
Normal file
317
src/components/Notes/Note.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
import {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { AlertPopup } from 'components/AlertPopup'
|
||||
import { CommentContent } from 'components/comment/CommentContent'
|
||||
import { Reactions } from 'components/comment/Reactions'
|
||||
import { Zap } from 'components/comment/Zap'
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useAppSelector, useDidMount, useNDKContext } from 'hooks'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import { UserProfile } from 'types'
|
||||
import { hexToNpub } from 'utils'
|
||||
import { NoteSubmit } from './NoteSubmit'
|
||||
import { NoteRepost } from './NoteRepost'
|
||||
|
||||
interface NoteProps {
|
||||
ndkEvent: NDKEvent
|
||||
}
|
||||
|
||||
export const Note = ({ ndkEvent }: NoteProps) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const userPubkey = userState.user?.pubkey as string | undefined
|
||||
const [eventProfile, setEventProfile] = useState<UserProfile>()
|
||||
const isRepost = ndkEvent.kind === NDKKind.Repost
|
||||
const [repostEvent, setRepostEvent] = useState<NDKEvent | undefined>()
|
||||
const [repostProfile, setRepostProfile] = useState<UserProfile | undefined>()
|
||||
const noteEvent = repostEvent ?? ndkEvent
|
||||
const noteProfile = repostProfile ?? eventProfile
|
||||
const { commentEvents } = useComments(ndkEvent.pubkey, undefined, ndkEvent.id)
|
||||
const [quoteRepostEvents, setQuoteRepostEvents] = useState<NDKEvent[]>([])
|
||||
const [hasQuoted, setHasQuoted] = useState(false)
|
||||
const [repostEvents, setRepostEvents] = useState<NDKEvent[]>([])
|
||||
const [hasReposted, setHasReposted] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showRepostPopup, setShowRepostPopup] = useState(false)
|
||||
const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false)
|
||||
|
||||
useDidMount(() => {
|
||||
setIsLoading(true)
|
||||
ndkEvent.author.fetchProfile().then((res) => setEventProfile(res))
|
||||
|
||||
if (isRepost) {
|
||||
const parsedEvent = JSON.parse(ndkEvent.content)
|
||||
const ndkRepostEvent = new NDKEvent(ndk, parsedEvent)
|
||||
setRepostEvent(ndkRepostEvent)
|
||||
ndkRepostEvent.author.fetchProfile().then((res) => setRepostProfile(res))
|
||||
}
|
||||
|
||||
const repostFilter: NDKFilter = {
|
||||
kinds: [NDKKind.Repost],
|
||||
'#e': [ndkEvent.id]
|
||||
}
|
||||
const quoteFilter: NDKFilter = {
|
||||
kinds: [NDKKind.Text],
|
||||
'#q': [ndkEvent.id]
|
||||
}
|
||||
ndk
|
||||
.fetchEvents([repostFilter, quoteFilter], {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
|
||||
if (ndkEventSet.size) {
|
||||
const quoteRepostEvents = ndkEvents.filter(
|
||||
(n) => n.kind === NDKKind.Text
|
||||
)
|
||||
userPubkey &&
|
||||
setHasQuoted(
|
||||
quoteRepostEvents.some((qr) => qr.pubkey === userPubkey)
|
||||
)
|
||||
setQuoteRepostEvents(quoteRepostEvents)
|
||||
|
||||
const repostEvents = ndkEvents.filter(
|
||||
(n) => n.kind === NDKKind.Repost
|
||||
)
|
||||
userPubkey &&
|
||||
setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey))
|
||||
setRepostEvents(repostEvents)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: noteEvent.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
const reposterRoute = repostEvent
|
||||
? getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: ndkEvent.pubkey
|
||||
})
|
||||
)
|
||||
: undefined
|
||||
|
||||
const baseUrl = appRoutes.feed + '/'
|
||||
|
||||
// Did user already repost this
|
||||
|
||||
// Show who reposted the note
|
||||
const reposterVisual =
|
||||
repostEvent && reposterRoute ? (
|
||||
<>
|
||||
<div className='IBMSMSMBSSCL_CommentRepost'>
|
||||
<div className='IBMSMSMBSSCL_CommentRepostVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSSCL_CommentRepostText'>
|
||||
<Link to={reposterRoute}>
|
||||
{eventProfile?.displayName || eventProfile?.name || ''}{' '}
|
||||
</Link>
|
||||
Reposted...
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
|
||||
const handleQuoteRepost = async (confirm: boolean) => {
|
||||
setShowQuoteRepostPopup(false)
|
||||
|
||||
if (!confirm) return
|
||||
}
|
||||
|
||||
const handleRepost = async (confirm: boolean) => {
|
||||
setShowRepostPopup(false)
|
||||
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
|
||||
const repostNdkEvent = await ndkEvent.repost(false)
|
||||
await repostNdkEvent.sign()
|
||||
}
|
||||
|
||||
// Is this user's repost?
|
||||
const isUsersRepost =
|
||||
isRepost && ndkEvent.author.pubkey === userState.user?.pubkey
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_CommentWrapper'>
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
{reposterVisual}
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
noteProfile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{noteProfile?.displayName || noteProfile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(noteEvent.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{noteEvent.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(noteEvent.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(noteEvent.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
<CommentContent content={noteEvent.content} />
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...noteEvent.rawEvent()} />
|
||||
{/* Quote Repost, Kind 1 */}
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||
}`}
|
||||
onClick={
|
||||
isLoading ? undefined : () => setShowQuoteRepostPopup(true)
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{isLoading ? <Dots /> : quoteRepostEvents.length}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* TODO: new popup */}
|
||||
{showQuoteRepostPopup && (
|
||||
<AlertPopup
|
||||
handleConfirm={handleQuoteRepost}
|
||||
handleClose={() => setShowQuoteRepostPopup(false)}
|
||||
header={'Quote Repost'}
|
||||
label={``}
|
||||
yesButtonLabel='Post'
|
||||
noButtonLabel='Cancel'
|
||||
>
|
||||
<NoteSubmit
|
||||
initialContent={`\n\n${
|
||||
repostEvent?.encode() || ndkEvent.encode()
|
||||
}`}
|
||||
/>
|
||||
</AlertPopup>
|
||||
)}
|
||||
{/* Repost, Kind 6 */}
|
||||
|
||||
{!isUsersRepost && (
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||
}`}
|
||||
onClick={
|
||||
isLoading || hasReposted
|
||||
? undefined
|
||||
: () => setShowRepostPopup(true)
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{isLoading ? <Dots /> : repostEvents.length}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showRepostPopup && (
|
||||
<NoteRepost
|
||||
ndkEvent={repostEvent || ndkEvent}
|
||||
handleConfirm={handleRepost}
|
||||
handleClose={() => setShowRepostPopup(false)}
|
||||
/>
|
||||
)}
|
||||
{typeof noteProfile?.lud16 !== 'undefined' &&
|
||||
noteProfile.lud16 !== '' && <Zap {...noteEvent.rawEvent()} />}
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
|
||||
to={baseUrl + noteEvent.encode()}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{commentEvents.length}
|
||||
</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
|
||||
</Link>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
|
||||
to={baseUrl + noteEvent.encode()}
|
||||
>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
23
src/components/Notes/NotePreview.tsx
Normal file
23
src/components/Notes/NotePreview.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { NoteRender } from './NoteRender'
|
||||
|
||||
interface NotePreviewProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
export const NotePreview = ({ content }: NotePreviewProps) => {
|
||||
return (
|
||||
<div className='feedPostsPostPreview'>
|
||||
<div className='feedPostsPostPreviewNote'>
|
||||
<p>
|
||||
Previewing post
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
<div className='IBMSMSMBSSCL_CBText'>
|
||||
<NoteRender content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/components/Notes/NoteQuoteRepostPopup.tsx
Normal file
1
src/components/Notes/NoteQuoteRepostPopup.tsx
Normal file
@ -0,0 +1 @@
|
||||
//TODO: quote repost popup
|
86
src/components/Notes/NoteRender.tsx
Normal file
86
src/components/Notes/NoteRender.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { ProfileLink } from 'components/ProfileSection'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { BlogPreview } from './internal/BlogPreview'
|
||||
import { ModPreview } from './internal/ModPreview'
|
||||
import { NoteWrapper } from './internal/NoteWrapper'
|
||||
|
||||
interface NoteRenderProps {
|
||||
content: string
|
||||
}
|
||||
const link =
|
||||
/(?:https?:\/\/|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]+(?::\d+)?)(?:[/?#][\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]*)?/gu
|
||||
const nostrMention =
|
||||
/(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi
|
||||
const nostrEntity =
|
||||
/(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi
|
||||
|
||||
export const NoteRender = ({ content }: NoteRenderProps) => {
|
||||
const _content = useMemo(() => {
|
||||
if (!content) return
|
||||
|
||||
const parts = content.split(
|
||||
new RegExp(`(${link.source})|(${nostrMention.source})`, 'gui')
|
||||
)
|
||||
|
||||
const _parts = parts.map((part, index) => {
|
||||
if (link.test(part)) {
|
||||
const [href] = part.match(link) || []
|
||||
return (
|
||||
<a key={index} href={href}>
|
||||
{href}
|
||||
</a>
|
||||
)
|
||||
} else if (nostrMention.test(part)) {
|
||||
const [encoded] = part.match(nostrEntity) || []
|
||||
|
||||
if (!encoded) return part
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(encoded)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'nprofile':
|
||||
return <ProfileLink key={index} pubkey={decoded.data.pubkey} />
|
||||
case 'npub':
|
||||
return <ProfileLink key={index} pubkey={decoded.data} />
|
||||
case 'note':
|
||||
return <NoteWrapper key={index} noteEntity={encoded} />
|
||||
case 'nevent':
|
||||
return <NoteWrapper key={index} noteEntity={encoded} />
|
||||
case 'naddr':
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{handleNaddr(decoded.data, part)}
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
default:
|
||||
return part
|
||||
}
|
||||
} catch (error) {
|
||||
return part
|
||||
}
|
||||
} else {
|
||||
return part
|
||||
}
|
||||
})
|
||||
return _parts
|
||||
}, [content])
|
||||
|
||||
return _content
|
||||
}
|
||||
|
||||
function handleNaddr(data: nip19.AddressPointer, original: string) {
|
||||
const { kind } = data
|
||||
|
||||
if (kind === NDKKind.Article) {
|
||||
return <BlogPreview {...data} original={original} />
|
||||
} else if (kind === NDKKind.Classified) {
|
||||
return <ModPreview {...data} original={original} />
|
||||
} else {
|
||||
return <>{original}</>
|
||||
}
|
||||
}
|
117
src/components/Notes/NoteRepost.tsx
Normal file
117
src/components/Notes/NoteRepost.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { AlertPopup } from 'components/AlertPopup'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CommentContent } from 'components/comment/CommentContent'
|
||||
import { getProfilePageRoute } from 'routes'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { UserProfile } from 'types'
|
||||
import { hexToNpub } from 'utils'
|
||||
import { formatDate } from 'date-fns'
|
||||
|
||||
interface NoteRepostProps {
|
||||
ndkEvent: NDKEvent
|
||||
handleConfirm: (confirm: boolean) => void
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const NoteRepost = ({
|
||||
ndkEvent,
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: NoteRepostProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const userPubkey = userState.user?.pubkey as string | undefined
|
||||
const [content, setContent] = useState<string>('')
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(async () => {
|
||||
const repost = await ndkEvent.repost(false)
|
||||
setContent(JSON.parse(repost.content).content)
|
||||
ndkEvent.author.fetchProfile().then((res) => setProfile(res))
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: ndkEvent.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
const reposterRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: userPubkey!
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<AlertPopup
|
||||
handleConfirm={handleConfirm}
|
||||
handleClose={handleClose}
|
||||
header={'Repost'}
|
||||
label={`Repost this?`}
|
||||
yesButtonLabel='Repost Now'
|
||||
noButtonLabel='Never mind'
|
||||
>
|
||||
<div className='IBMSMSMBSSCL_CommentQP'>
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentRepost'>
|
||||
<div className='IBMSMSMBSSCL_CommentRepostVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSSCL_CommentRepostText'>
|
||||
<Link to={reposterRoute}>
|
||||
{userState.user?.displayName || userState.user?.name || ''}{' '}
|
||||
</Link>
|
||||
Reposted...
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(ndkEvent.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{ndkEvent.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(ndkEvent.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(ndkEvent.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
<CommentContent content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertPopup>
|
||||
)
|
||||
}
|
121
src/components/Notes/NoteSubmit.tsx
Normal file
121
src/components/Notes/NoteSubmit.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'
|
||||
import { FALLBACK_PROFILE_IMAGE } from '../../constants'
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { useProfile } from 'hooks/useProfile'
|
||||
import { Navigate, useSubmit } from 'react-router-dom'
|
||||
import { appRoutes } from 'routes'
|
||||
import { useState } from 'react'
|
||||
import { adjustTextareaHeight } from 'utils'
|
||||
import { NotePreview } from './NotePreview'
|
||||
|
||||
interface NoteSubmitProps {
|
||||
initialContent?: string | undefined
|
||||
}
|
||||
|
||||
export const NoteSubmit = ({ initialContent }: NoteSubmitProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const profile = useProfile(userState.user?.pubkey as string | undefined, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
||||
})
|
||||
const [content, setContent] = useState(initialContent ?? '')
|
||||
const [nsfw, setNsfw] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const image = profile?.image || FALLBACK_PROFILE_IMAGE
|
||||
|
||||
const submit = useSubmit()
|
||||
|
||||
const handleContentChange = (
|
||||
event: React.ChangeEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
setContent(event.currentTarget.value)
|
||||
adjustTextareaHeight(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const formSubmit = {
|
||||
content,
|
||||
nsfw
|
||||
}
|
||||
submit(JSON.stringify(formSubmit), {
|
||||
method: 'post',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
const handlePreviewToggle = () => {
|
||||
setShowPreview((prev) => !prev)
|
||||
}
|
||||
|
||||
if (!userState.user?.pubkey) return <Navigate to={appRoutes.home} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className='feedPostsPost' onSubmit={handleFormSubmit}>
|
||||
<div className='feedPostsPostInside'>
|
||||
<div className='feedPostsPostInsideInputWrapper'>
|
||||
<div className='feedPostsPostInsidePP'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
style={{
|
||||
background: `url(${image}) center/cover no-repeat`,
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id='postSocialTextarea'
|
||||
name='content'
|
||||
className='inputMain feedPostsPostInsideInput'
|
||||
placeholder='Watcha thinking about?'
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
</div>
|
||||
{showPreview && <NotePreview content={content} />}
|
||||
<div className='feedPostsPostInsideAction'>
|
||||
<div
|
||||
className='inputLabelWrapperMain inputLabelWrapperMainAlt'
|
||||
style={{ width: 'unset' }}
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
checked={nsfw}
|
||||
id='nsfw'
|
||||
name='nsfw'
|
||||
onChange={() => setNsfw((nsfw) => !nsfw)}
|
||||
/>
|
||||
<label htmlFor='nsfw' className='form-label labelMain'>
|
||||
NSFW
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'row', gridGap: '10px' }}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
style={{ padding: '5px 20px', borderRadius: '8px' }}
|
||||
onClick={handlePreviewToggle}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='submit'
|
||||
style={{ padding: '5px 20px', borderRadius: '8px' }}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
25
src/components/Notes/internal/BlogPreview.tsx
Normal file
25
src/components/Notes/internal/BlogPreview.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getBlogPageRoute } from 'routes'
|
||||
import { truncate } from 'utils'
|
||||
|
||||
type BlogPreviewProps = nip19.AddressPointer & {
|
||||
original: string
|
||||
}
|
||||
|
||||
export const BlogPreview = ({
|
||||
identifier,
|
||||
kind,
|
||||
pubkey,
|
||||
original
|
||||
}: BlogPreviewProps) => {
|
||||
const route = getBlogPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier,
|
||||
pubkey,
|
||||
kind
|
||||
})
|
||||
)
|
||||
|
||||
return <Link to={route}>{truncate(original)}</Link>
|
||||
}
|
25
src/components/Notes/internal/ModPreview.tsx
Normal file
25
src/components/Notes/internal/ModPreview.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { truncate } from 'utils'
|
||||
|
||||
type ModPreviewProps = nip19.AddressPointer & {
|
||||
original: string
|
||||
}
|
||||
|
||||
export const ModPreview = ({
|
||||
identifier,
|
||||
kind,
|
||||
pubkey,
|
||||
original
|
||||
}: ModPreviewProps) => {
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier,
|
||||
pubkey,
|
||||
kind
|
||||
})
|
||||
)
|
||||
|
||||
return <Link to={route}>{truncate(original)}</Link>
|
||||
}
|
77
src/components/Notes/internal/NoteWrapper.tsx
Normal file
77
src/components/Notes/internal/NoteWrapper.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useDidMount, useNDKContext } from 'hooks'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import { filterFromId, NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { useState } from 'react'
|
||||
import { UserProfile } from 'types'
|
||||
import { hexToNpub } from 'utils'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { CommentContent } from 'components/comment/CommentContent'
|
||||
import { Dots } from 'components/Spinner'
|
||||
|
||||
interface NoteWrapperProps {
|
||||
noteEntity: string
|
||||
}
|
||||
export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => {
|
||||
const { fetchEvent } = useNDKContext()
|
||||
const [note, setNote] = useState<NDKEvent>()
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
useDidMount(() => {
|
||||
const filter = filterFromId(noteEntity)
|
||||
fetchEvent(filter).then((ndkEvent) => {
|
||||
if (ndkEvent) {
|
||||
setNote(ndkEvent)
|
||||
ndkEvent.author.fetchProfile().then((res) => setProfile(res))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = note?.author.nprofile
|
||||
? getProfilePageRoute(note?.author.nprofile)
|
||||
: appRoutes.home
|
||||
|
||||
if (!note) return <Dots />
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_CommentQP'>
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(note.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{note.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(note.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(note.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
<CommentContent content={note.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,3 +1,166 @@
|
||||
import { useLoaderData } from 'react-router-dom'
|
||||
import { FeedPageLoaderResult } from './loader'
|
||||
import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks'
|
||||
import { FilterOptions, NSFWFilter } from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { NoteSubmit } from 'components/Notes/NoteSubmit'
|
||||
import { Note } from 'components/Notes/Note'
|
||||
|
||||
export const FeedTabPosts = () => {
|
||||
return <>WIP: Posts</>
|
||||
const SHOWING_STEP = 20
|
||||
const { followList } = useLoaderData() as FeedPageLoaderResult
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
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 [notes, setNotes] = useState<NDKEvent[]>([])
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true)
|
||||
const [showing, setShowing] = useState(SHOWING_STEP)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userPubkey) return
|
||||
|
||||
setIsFetching(true)
|
||||
setIsLoadMoreVisible(true)
|
||||
|
||||
const filter: NDKFilter = {
|
||||
authors: [...followList, userPubkey],
|
||||
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)
|
||||
})
|
||||
}, [followList, ndk, userPubkey])
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
let _notes = notes || []
|
||||
|
||||
// Filter nsfw (Hide_NSFW option)
|
||||
_notes = _notes.filter(
|
||||
(n) =>
|
||||
!(
|
||||
filterOptions.nsfw === NSFWFilter.Hide_NSFW &&
|
||||
n.tagValue('L') === 'content-warning'
|
||||
)
|
||||
)
|
||||
|
||||
// Filter source
|
||||
_notes = _notes.filter(
|
||||
(n) =>
|
||||
!(
|
||||
filterOptions.source === window.location.host &&
|
||||
n
|
||||
.getMatchingTags('l')
|
||||
.some((l) => l[1] === window.location.host && l[2] === 'source')
|
||||
)
|
||||
)
|
||||
|
||||
// Filter reply events
|
||||
_notes = _notes.filter(
|
||||
(n) =>
|
||||
n.getMatchingTags('e', 'root').length +
|
||||
n.getMatchingTags('e', 'reply').length ===
|
||||
0
|
||||
)
|
||||
|
||||
_notes = _notes.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||
|
||||
showing > 0 && _notes.splice(showing)
|
||||
return _notes
|
||||
}, [filterOptions.nsfw, filterOptions.source, notes, showing])
|
||||
|
||||
if (!userPubkey) return null
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const LOAD_MORE_STEP = SHOWING_STEP * 2
|
||||
setShowing((prev) => prev + SHOWING_STEP)
|
||||
const lastNote = filteredNotes[filteredNotes.length - 1]
|
||||
const filter: NDKFilter = {
|
||||
authors: [...followList, userPubkey],
|
||||
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 (
|
||||
<>
|
||||
<NoteSubmit />
|
||||
{isFetching && <LoadingSpinner desc='Fetching notes from relays' />}
|
||||
{filteredNotes.length === 0 && !isFetching && (
|
||||
<div className='IBMSMListFeedNoPosts'>
|
||||
<p>You aren't following people (or 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user