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
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m5s
Reviewed-on: #231
This commit is contained in:
commit
e6b27af709
18
package-lock.json
generated
18
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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,38 +43,69 @@ 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 */}
|
||||||
<Dropdown
|
{/* show only if not posts tabs */}
|
||||||
label={
|
{tab !== 2 && (
|
||||||
filterOptions.source === window.location.host
|
<Dropdown
|
||||||
? `Show From: ${filterOptions.source}`
|
label={
|
||||||
: 'Show All'
|
filterOptions.source === window.location.host
|
||||||
}
|
? `Show From: ${filterOptions.source}`
|
||||||
>
|
: 'Show All'
|
||||||
<Option
|
|
||||||
onClick={() =>
|
|
||||||
setFilterOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
source: window.location.host
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Show From: {window.location.host}
|
<Option
|
||||||
</Option>
|
onClick={() =>
|
||||||
<Option
|
setFilterOptions((prev) => ({
|
||||||
onClick={() =>
|
...prev,
|
||||||
setFilterOptions((prev) => ({
|
source: window.location.host
|
||||||
...prev,
|
}))
|
||||||
source: 'Show All'
|
}
|
||||||
}))
|
>
|
||||||
}
|
Show From: {window.location.host}
|
||||||
>
|
</Option>
|
||||||
Show All
|
<Option
|
||||||
</Option>
|
onClick={() =>
|
||||||
</Dropdown>
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
source: 'Show All'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show All
|
||||||
|
</Option>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repost filter */}
|
||||||
|
{tab === 2 && (
|
||||||
|
<Dropdown label={filterOptions.repost}>
|
||||||
|
{Object.values(RepostFilter).map((item, index) =>
|
||||||
|
item === RepostFilter.Only_Repost ? null : (
|
||||||
|
<Option
|
||||||
|
key={`repost-${index}`}
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
repost: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Filter>
|
</Filter>
|
||||||
|
@ -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,29 +34,34 @@ export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Object.values(NSFWFilter).map((item, index) => (
|
{Object.values(NSFWFilter).map((item, index) => {
|
||||||
<Option
|
// Posts feed filter exception
|
||||||
key={`nsfwFilterItem-${index}`}
|
if (item === NSFWFilter.Only_NSFW && skipOnlyNsfw) return null
|
||||||
onClick={() => {
|
|
||||||
// Trigger NSFW popup
|
return (
|
||||||
if (
|
<Option
|
||||||
(item === NSFWFilter.Only_NSFW ||
|
key={`nsfwFilterItem-${index}`}
|
||||||
item === NSFWFilter.Show_NSFW) &&
|
onClick={() => {
|
||||||
!confirmNsfw
|
// Trigger NSFW popup
|
||||||
) {
|
if (
|
||||||
setSelectedNsfwOption(item)
|
(item === NSFWFilter.Only_NSFW ||
|
||||||
setShowNsfwPopup(true)
|
item === NSFWFilter.Show_NSFW) &&
|
||||||
} else {
|
!confirmNsfw
|
||||||
setFilterOptions((prev) => ({
|
) {
|
||||||
...prev,
|
setSelectedNsfwOption(item)
|
||||||
nsfw: item
|
setShowNsfwPopup(true)
|
||||||
}))
|
} else {
|
||||||
}
|
setFilterOptions((prev) => ({
|
||||||
}}
|
...prev,
|
||||||
>
|
nsfw: item
|
||||||
{item}
|
}))
|
||||||
</Option>
|
}
|
||||||
))}
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{showNsfwPopup && (
|
{showNsfwPopup && (
|
||||||
<NsfwAlertPopup
|
<NsfwAlertPopup
|
||||||
handleConfirm={handleConfirm}
|
handleConfirm={handleConfirm}
|
||||||
|
@ -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) {
|
||||||
const parsedEvent = JSON.parse(ndkEvent.content)
|
try {
|
||||||
const ndkRepostEvent = new NDKEvent(ndk, parsedEvent)
|
const parsedEvent = JSON.parse(ndkEvent.content)
|
||||||
setRepostEvent(ndkRepostEvent)
|
const ndkRepostEvent = new NDKEvent(ndk, parsedEvent)
|
||||||
ndkRepostEvent.author.fetchProfile().then((res) => setRepostProfile(res))
|
setRepostEvent(ndkRepostEvent)
|
||||||
|
ndkRepostEvent.author
|
||||||
|
.fetchProfile()
|
||||||
|
.then((res) => setRepostProfile(res))
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'Event content malformed',
|
||||||
|
error,
|
||||||
|
ndkEvent.content
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log(true, LogType.Error, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const repostFilter: NDKFilter = {
|
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>
|
||||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
<NsfwCommentWrapper
|
||||||
<CommentContent content={noteEvent.content} />
|
id={ndkEvent.id}
|
||||||
</div>
|
isNsfw={isNsfw}
|
||||||
|
hideNsfwActive={NSFWFilter.Hide_NSFW === filterOptions.nsfw}
|
||||||
|
>
|
||||||
|
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||||
|
<CommentContent content={noteEvent.content} isNsfw={isNsfw} />
|
||||||
|
</div>
|
||||||
|
</NsfwCommentWrapper>
|
||||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||||
<Reactions {...noteEvent.rawEvent()} />
|
<Reactions {...noteEvent.rawEvent()} />
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
@ -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,20 +30,87 @@ 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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCache({
|
||||||
|
content,
|
||||||
|
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
|
||||||
|
setContent('')
|
||||||
|
setNsfw(false)
|
||||||
|
|
||||||
|
submit(JSON.stringify(formSubmit), {
|
||||||
|
method: 'post',
|
||||||
|
encType: 'application/json',
|
||||||
|
action: appRoutes.feed
|
||||||
|
})
|
||||||
|
|
||||||
|
typeof handleClose === 'function' && handleClose()
|
||||||
|
},
|
||||||
|
[content, handleClose, nsfw, submit]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTryAgainConfirm = useCallback(
|
||||||
|
(confirm: boolean) => {
|
||||||
|
setShowTryAgainPopup(false)
|
||||||
|
|
||||||
|
// Cancel if not confirmed
|
||||||
|
if (!confirm) return
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setContent('')
|
||||||
|
setNsfw(false)
|
||||||
|
|
||||||
|
handleFormSubmit()
|
||||||
|
},
|
||||||
|
[handleFormSubmit]
|
||||||
|
)
|
||||||
const handleContentChange = (
|
const handleContentChange = (
|
||||||
event: React.ChangeEvent<HTMLTextAreaElement>
|
event: React.ChangeEvent<HTMLTextAreaElement>
|
||||||
) => {
|
) => {
|
||||||
@ -43,25 +118,6 @@ export const NoteSubmit = ({
|
|||||||
adjustTextareaHeight(event.currentTarget)
|
adjustTextareaHeight(event.currentTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const formSubmit = {
|
|
||||||
content,
|
|
||||||
nsfw
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setContent('')
|
|
||||||
setNsfw(false)
|
|
||||||
|
|
||||||
submit(JSON.stringify(formSubmit), {
|
|
||||||
method: 'post',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
|
|
||||||
typeof handleClose === 'function' && handleClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePreviewToggle = () => {
|
const handlePreviewToggle = () => {
|
||||||
setShowPreview((prev) => !prev)
|
setShowPreview((prev) => !prev)
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
||||||
|
60
src/components/NsfwCommentWrapper.tsx
Normal file
60
src/components/NsfwCommentWrapper.tsx
Normal 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'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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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,32 +581,51 @@ 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
|
||||||
let nprofile: string | undefined
|
}
|
||||||
const npub = hexToNpub(pubkey)
|
export const ProfileLink = ({ pubkey, nip05 }: ProfileLinkProps) => {
|
||||||
|
const { ndk } = useNDKContext()
|
||||||
try {
|
const [hexPubkey, setHexPubkey] = useState<string>()
|
||||||
hexPubkey = npubToHex(pubkey)
|
const profile = useProfile(hexPubkey, {
|
||||||
|
|
||||||
if (hexPubkey) {
|
|
||||||
nprofile = hexPubkey
|
|
||||||
? nip19.nprofileEncode({
|
|
||||||
pubkey: hexPubkey
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently ignore
|
|
||||||
log(true, LogType.Error, 'Failed to encode profile.', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
|
|
||||||
const profile = useProfile(hexPubkey!, {
|
|
||||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL
|
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
|
||||||
|
try {
|
||||||
|
if (hexPubkey) {
|
||||||
|
nprofile = hexPubkey
|
||||||
|
? nip19.nprofileEncode({
|
||||||
|
pubkey: hexPubkey
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore
|
||||||
|
log(true, LogType.Error, 'Failed to encode profile.', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
|
||||||
|
}, [hexPubkey])
|
||||||
|
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
const npub = hexPubkey ? hexToNpub(hexPubkey) : ''
|
||||||
|
const displayName = profile?.displayName || profile?.name || truncate(npub)
|
||||||
|
return displayName
|
||||||
|
}, [hexPubkey, profile?.displayName, profile?.name])
|
||||||
|
|
||||||
const displayName = profile?.displayName || profile?.name || truncate(npub)
|
|
||||||
return <Link to={profileRoute}>@{displayName}</Link>
|
return <Link to={profileRoute}>@{displayName}</Link>
|
||||||
}
|
}
|
||||||
|
@ -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,25 +188,82 @@ 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 */}
|
||||||
<svg
|
<div
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||||
viewBox='0 -64 640 640'
|
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||||
width='1em'
|
}`}
|
||||||
height='1em'
|
onClick={
|
||||||
fill='currentColor'
|
navigation.state === 'idle'
|
||||||
className='IBMSMSMBSSCL_CAElementIcon'
|
? () => setShowQuoteRepostPopup(true)
|
||||||
>
|
: undefined
|
||||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
}
|
||||||
</svg>
|
>
|
||||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
<svg
|
||||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
viewBox='0 0 512 512'
|
||||||
</div>
|
width='1em'
|
||||||
</div> */}
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMBSSCL_CAElementIcon'
|
||||||
|
>
|
||||||
|
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
|
||||||
|
</svg>
|
||||||
|
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||||
|
{quoteRepostEvents.length}
|
||||||
|
</p>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showQuoteRepostPopup && (
|
||||||
|
<NoteQuoteRepostPopup
|
||||||
|
ndkEvent={comment.event}
|
||||||
|
handleClose={() => setShowQuoteRepostPopup(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repost, Kind 6 */}
|
||||||
|
<div
|
||||||
|
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||||
|
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||||
|
}`}
|
||||||
|
onClick={
|
||||||
|
navigation.state === 'idle'
|
||||||
|
? () => setShowRepostPopup(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 -64 640 640'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMBSSCL_CAElementIcon'
|
||||||
|
>
|
||||||
|
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||||
|
</svg>
|
||||||
|
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||||
|
{repostEvents.length}
|
||||||
|
</p>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showRepostPopup && (
|
||||||
|
<NoteRepostPopup
|
||||||
|
ndkEvent={comment.event}
|
||||||
|
handleConfirm={handleRepost}
|
||||||
|
handleClose={() => setShowRepostPopup(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
|
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
|
||||||
<Zap {...comment.event.rawEvent()} />
|
<Zap {...comment.event.rawEvent()} />
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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,25 +324,80 @@ 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
|
||||||
<svg
|
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
hasQuoted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||||
viewBox='0 -64 640 640'
|
}`}
|
||||||
width='1em'
|
onClick={
|
||||||
height='1em'
|
isLoading
|
||||||
fill='currentColor'
|
? undefined
|
||||||
className='IBMSMSMBSSCL_CAElementIcon'
|
: () => setShowQuoteRepostPopup(true)
|
||||||
>
|
}
|
||||||
<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>
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
viewBox='0 0 512 512'
|
||||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
width='1em'
|
||||||
</div>
|
height='1em'
|
||||||
</div> */}
|
fill='currentColor'
|
||||||
|
className='IBMSMSMBSSCL_CAElementIcon'
|
||||||
|
>
|
||||||
|
<path d='M256 31.1c-141.4 0-255.1 93.09-255.1 208c0 49.59 21.38 94.1 56.97 130.7c-12.5 50.39-54.31 95.3-54.81 95.8C0 468.8-.5938 472.2 .6875 475.2c1.312 3 4.125 4.797 7.312 4.797c66.31 0 116-31.8 140.6-51.41c32.72 12.31 69.01 19.41 107.4 19.41C397.4 447.1 512 354.9 512 239.1S397.4 31.1 256 31.1zM368 266c0 8.836-7.164 16-16 16h-54V336c0 8.836-7.164 16-16 16h-52c-8.836 0-16-7.164-16-16V282H160c-8.836 0-16-7.164-16-16V214c0-8.838 7.164-16 16-16h53.1V144c0-8.838 7.164-16 16-16h52c8.836 0 16 7.162 16 16v54H352c8.836 0 16 7.162 16 16V266z'></path>
|
||||||
|
</svg>
|
||||||
|
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||||
|
{isLoading ? <Dots /> : quoteRepostEvents.length}
|
||||||
|
</p>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showQuoteRepostPopup && (
|
||||||
|
<NoteQuoteRepostPopup
|
||||||
|
ndkEvent={event}
|
||||||
|
handleClose={() => setShowQuoteRepostPopup(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repost, Kind 6 */}
|
||||||
|
<div
|
||||||
|
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost ${
|
||||||
|
hasReposted ? 'IBMSMSMBSSCL_CAERepostActive' : ''
|
||||||
|
}`}
|
||||||
|
onClick={
|
||||||
|
isLoading || hasReposted
|
||||||
|
? undefined
|
||||||
|
: () => setShowRepostPopup(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 -64 640 640'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
className='IBMSMSMBSSCL_CAElementIcon'
|
||||||
|
>
|
||||||
|
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||||
|
</svg>
|
||||||
|
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||||
|
{isLoading ? <Dots /> : repostEvents.length}
|
||||||
|
</p>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||||
|
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showRepostPopup && (
|
||||||
|
<NoteRepostPopup
|
||||||
|
ndkEvent={event}
|
||||||
|
handleConfirm={handleRepost}
|
||||||
|
handleClose={() => setShowRepostPopup(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{typeof profile?.lud16 !== 'undefined' &&
|
{typeof profile?.lud16 !== 'undefined' &&
|
||||||
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
|
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
|
||||||
|
@ -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]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
return [...prev]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event is a direct reply
|
||||||
return [{ event: ndkEvent }, ...prev]
|
return [{ event: ndkEvent }, ...prev]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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])
|
||||||
}
|
}
|
||||||
|
@ -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'>
|
||||||
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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 {
|
||||||
|
@ -40,3 +40,5 @@ export interface FilterOptions {
|
|||||||
wot: WOTFilterOptions
|
wot: WOTFilterOptions
|
||||||
repost: RepostFilter
|
repost: RepostFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FeedPostsFilter = Pick<FilterOptions, 'nsfw' | 'repost'>
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -104,3 +104,5 @@ export function handleCommentSubmit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NOTE_DRAFT_CACHE_KEY = 'draft-note'
|
||||||
|
@ -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' })
|
||||||
|
Loading…
x
Reference in New Issue
Block a user