diff --git a/package-lock.json b/package-lock.json index ede0779..cbec342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@getalby/lightning-tools": "5.0.3", "@mdxeditor/editor": "^3.20.0", - "@nostr-dev-kit/ndk": "2.11.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", + "@nostr-dev-kit/ndk": "2.11.2", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.11", "@reduxjs/toolkit": "2.2.6", "@types/react-helmet": "^6.1.11", "axios": "^1.7.9", @@ -2134,9 +2134,9 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.0.tgz", - "integrity": "sha512-FKIMtcVsVcquzrC+yir9lOXHCIHmQ3IKEVCMohqEB7N96HjP2qrI9s5utbjI3lkavFNF5tXg1Gp9ODEo7XCfLA==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.11.2.tgz", + "integrity": "sha512-DNrodIBC0j2MqEUQ5Mqaa671iZiRiKluu0c/wLkX7PCva07KSPyvcuyGp5fhk+/EZBurwZccMaML0syH0Qu8kQ==", "license": "MIT", "dependencies": { "@noble/curves": "^1.6.0", @@ -2156,12 +2156,12 @@ } }, "node_modules/@nostr-dev-kit/ndk-cache-dexie": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.9.tgz", - "integrity": "sha512-SZ5FjON0QPekiC7oW9Hy3JQxG0Oxxtud9LBa1q/A49JV/Qppv1x37nFHxi0XLxEbDgFTNYbaN27Zjfp2NPem2g==", + "version": "2.5.11", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.11.tgz", + "integrity": "sha512-lhoKcjwxlNB2rrnZ2zDAGJeh5k7x1f51oAwUnlDAuPvNEe4q/2XynxnI3uTe7rBg9+pq085esOQK7pg75E+BgQ==", "license": "MIT", "dependencies": { - "@nostr-dev-kit/ndk": "2.11.0", + "@nostr-dev-kit/ndk": "2.11.2", "debug": "^4.3.7", "dexie": "^4.0.8", "nostr-tools": "^2.4.0", diff --git a/package.json b/package.json index 8901e62..64b90f8 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "dependencies": { "@getalby/lightning-tools": "5.0.3", "@mdxeditor/editor": "^3.20.0", - "@nostr-dev-kit/ndk": "2.11.0", - "@nostr-dev-kit/ndk-cache-dexie": "2.5.9", + "@nostr-dev-kit/ndk": "2.11.2", + "@nostr-dev-kit/ndk-cache-dexie": "2.5.11", "@reduxjs/toolkit": "2.2.6", "@types/react-helmet": "^6.1.11", "axios": "^1.7.9", diff --git a/src/components/Filters/FeedFilter.tsx b/src/components/Filters/FeedFilter.tsx index bdd8cb3..655d039 100644 --- a/src/components/Filters/FeedFilter.tsx +++ b/src/components/Filters/FeedFilter.tsx @@ -1,7 +1,7 @@ import React from 'react' import { PropsWithChildren } from 'react' import { Filter } from '.' -import { FilterOptions, SortBy } from 'types' +import { FilterOptions, RepostFilter, SortBy } from 'types' import { Dropdown } from './Dropdown' import { Option } from './Option' import { DEFAULT_FILTER_OPTIONS } from 'utils' @@ -43,38 +43,69 @@ export const FeedFilter = React.memo( {/* nsfw filter options */} - + {/* source filter options */} - - - - + + + + )} + + {/* Repost filter */} + {tab === 2 && ( + + {Object.values(RepostFilter).map((item, index) => + item === RepostFilter.Only_Repost ? null : ( + + ) + )} + + )} {children} diff --git a/src/components/Filters/NsfwFilterOptions.tsx b/src/components/Filters/NsfwFilterOptions.tsx index 05ab906..5fe7a45 100644 --- a/src/components/Filters/NsfwFilterOptions.tsx +++ b/src/components/Filters/NsfwFilterOptions.tsx @@ -7,9 +7,13 @@ import { DEFAULT_FILTER_OPTIONS } from 'utils' interface NsfwFilterOptionsProps { filterKey: string + skipOnlyNsfw?: boolean } -export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { +export const NsfwFilterOptions = ({ + filterKey, + skipOnlyNsfw +}: NsfwFilterOptionsProps) => { const [, setFilterOptions] = useLocalStorage( filterKey, DEFAULT_FILTER_OPTIONS @@ -30,29 +34,34 @@ export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { return ( <> - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + {Object.values(NSFWFilter).map((item, index) => { + // Posts feed filter exception + if (item === NSFWFilter.Only_NSFW && skipOnlyNsfw) return null + + return ( + + ) + })} {showNsfwPopup && ( { const { ndk } = useNDKContext() + const submit = useSubmit() + const navigation = useNavigation() const userState = useAppSelector((state) => state.user) const userPubkey = userState.user?.pubkey as string | undefined const [eventProfile, setEventProfile] = useState() const isRepost = ndkEvent.kind === NDKKind.Repost + const filterKey = 'filter-feed-2' + const [filterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + const isNsfw = ndkEvent + .getMatchingTags('L') + .some((t) => t[1] === 'content-warning') const [repostEvent, setRepostEvent] = useState() const [repostProfile, setRepostProfile] = useState() const noteEvent = repostEvent ?? ndkEvent @@ -48,10 +64,26 @@ export const Note = ({ ndkEvent }: NoteProps) => { 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)) + try { + const parsedEvent = JSON.parse(ndkEvent.content) + const ndkRepostEvent = new NDKEvent(ndk, parsedEvent) + 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 = { @@ -109,8 +141,6 @@ export const Note = ({ ndkEvent }: NoteProps) => { const baseUrl = appRoutes.feed + '/' - // Did user already repost this - // Show who reposted the note const reposterVisual = repostEvent && reposterRoute ? ( @@ -138,13 +168,26 @@ export const Note = ({ ndkEvent }: NoteProps) => { ) : null const handleRepost = async (confirm: boolean) => { + if (navigation.state !== 'idle') return + setShowRepostPopup(false) // Cancel if not confirmed if (!confirm) return 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? @@ -178,19 +221,31 @@ export const Note = ({ ndkEvent }: NoteProps) => { {noteEvent.created_at && ( )} -
- -
+ +
+ +
+
diff --git a/src/components/Notes/NoteQuoteRepostPopup.tsx b/src/components/Notes/NoteQuoteRepostPopup.tsx index b81de61..9dc6679 100644 --- a/src/components/Notes/NoteQuoteRepostPopup.tsx +++ b/src/components/Notes/NoteQuoteRepostPopup.tsx @@ -46,7 +46,7 @@ export const NoteQuoteRepostPopup = ({ Quote repost this?
diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx index 7ad2e23..907eeba 100644 --- a/src/components/Notes/NoteRender.tsx +++ b/src/components/Notes/NoteRender.tsx @@ -6,6 +6,8 @@ import { Fragment } from 'react/jsx-runtime' import { BlogPreview } from './internal/BlogPreview' import { ModPreview } from './internal/ModPreview' import { NoteWrapper } from './internal/NoteWrapper' +import { NIP05_REGEX } from 'nostr-tools/nip05' +import { isValidImageUrl, isValidUrl, isValidVideoUrl } from 'utils' interface NoteRenderProps { content: string @@ -16,20 +18,36 @@ const nostrMention = /(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi const nostrEntity = /(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi +const nostrNip5Mention = /(?:nostr:|@)([^\s]{1,64}@[^\s]+\.[^\s]{2,})/gi export const NoteRender = ({ content }: NoteRenderProps) => { const _content = useMemo(() => { if (!content) return 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) => { if (link.test(part)) { const [href] = part.match(link) || [] + + if (href && isValidUrl(href)) { + if (isValidImageUrl(href)) { + // Image + return + } else if (isValidVideoUrl(href)) { + // Video + return
+ {typeof formErrors?.content !== 'undefined' && ( + + )} {showPreview && } + {showTryAgainPopup && ( + setShowTryAgainPopup(false)} + header={'Post'} + label={`Posting timed out. Do you want to try again?`} + /> + )} diff --git a/src/components/Notes/internal/NoteWrapper.tsx b/src/components/Notes/internal/NoteWrapper.tsx index b8c9e74..13a704a 100644 --- a/src/components/Notes/internal/NoteWrapper.tsx +++ b/src/components/Notes/internal/NoteWrapper.tsx @@ -32,6 +32,8 @@ export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => { if (!note) return + const baseUrl = appRoutes.feed + '/' + return (
@@ -58,12 +60,18 @@ export const NoteWrapper = ({ noteEntity }: NoteWrapperProps) => {
{note.created_at && ( )}
diff --git a/src/components/NsfwCommentWrapper.tsx b/src/components/NsfwCommentWrapper.tsx new file mode 100644 index 0000000..b720fd8 --- /dev/null +++ b/src/components/NsfwCommentWrapper.tsx @@ -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) => { + // Have we approved show nsfw comment button + const [viewNsfwComment, setViewNsfwComment] = useSessionStorage( + id, + false + ) + + const [showNsfwPopup, setShowNsfwPopup] = useState(false) + const [confirmNsfw] = useLocalStorage('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 ( + <> +
+

This post is hidden as it's marked as NSFW

+ +
+ {showNsfwPopup && ( + setShowNsfwPopup(false)} + /> + )} + + ) +} diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index d00d289..0899910 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -1,7 +1,7 @@ import { FALLBACK_PROFILE_IMAGE } from 'constants.ts' import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { QRCodeSVG } from 'qrcode.react' -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' import { @@ -27,7 +27,11 @@ import { import { LoadingSpinner } from './LoadingSpinner' import { ZapPopUp } from './Zap' 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 { createPortal } from 'react-dom' @@ -577,32 +581,51 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => { ) } -export const ProfileLink = ({ pubkey }: Props) => { - let hexPubkey: string | null = null - let profileRoute: string | undefined = appRoutes.home - let nprofile: string | undefined - const npub = hexToNpub(pubkey) - - try { - hexPubkey = npubToHex(pubkey) - - 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!, { +type ProfileLinkProps = { + pubkey?: string + nip05?: string +} +export const ProfileLink = ({ pubkey, nip05 }: ProfileLinkProps) => { + const { ndk } = useNDKContext() + const [hexPubkey, setHexPubkey] = useState() + const profile = useProfile(hexPubkey, { cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }) + useEffect(() => { + if (pubkey) { + setHexPubkey(npubToHex(pubkey)!) + } else if (nip05) { + NDKUser.fromNip05(nip05, ndk).then((user) => { + if (user?.pubkey) { + setHexPubkey(npubToHex(user.pubkey)!) + } + }) + } + }, [pubkey, nip05, ndk]) + + const profileRoute = useMemo(() => { + let nprofile: string | undefined + 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 @{displayName} } diff --git a/src/components/comment/Comment.tsx b/src/components/comment/Comment.tsx index 44fcdd7..4b6f7f4 100644 --- a/src/components/comment/Comment.tsx +++ b/src/components/comment/Comment.tsx @@ -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 { useDidMount, useNDKContext } from 'hooks' +import { useAppSelector, useDidMount, useNDKContext } from 'hooks' import { useState } from 'react' -import { useParams, useLocation, Link } from 'react-router-dom' +import { + useParams, + useLocation, + Link, + useSubmit, + useNavigation +} from 'react-router-dom' import { getModPageRoute, getBlogPageRoute, @@ -15,6 +26,8 @@ import { Reactions } from './Reactions' import { Zap } from './Zap' import { nip19 } from 'nostr-tools' import { CommentContent } from './CommentContent' +import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup' +import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup' interface CommentProps { comment: CommentEvent @@ -38,6 +51,16 @@ export const Comment = ({ comment }: CommentProps) => { const [commentEvents, setCommentEvents] = useState([]) const [profile, setProfile] = useState() + const submit = useSubmit() + const navigation = useNavigation() + const [repostEvents, setRepostEvents] = useState([]) + const [quoteRepostEvents, setQuoteRepostEvents] = useState([]) + 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(() => { comment.event.author.fetchProfile().then((res) => setProfile(res)) 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( nip19.nprofileEncode({ @@ -107,25 +188,82 @@ export const Comment = ({ comment }: CommentProps) => {
- {/*
- - - -

0

-
-
-
-
*/} + + {comment.event.kind === NDKKind.Text && ( + <> + {/* Quote Repost, Kind 1 */} +
setShowQuoteRepostPopup(true) + : undefined + } + > + + + +

+ {quoteRepostEvents.length} +

+
+
+
+
+ {showQuoteRepostPopup && ( + setShowQuoteRepostPopup(false)} + /> + )} + + {/* Repost, Kind 6 */} +
setShowRepostPopup(true) + : undefined + } + > + + + +

+ {repostEvents.length} +

+
+
+
+
+ {showRepostPopup && ( + setShowRepostPopup(false)} + /> + )} + + )} + {typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && ( )} diff --git a/src/components/comment/CommentContent.tsx b/src/components/comment/CommentContent.tsx index 1657d89..e1d9b88 100644 --- a/src/components/comment/CommentContent.tsx +++ b/src/components/comment/CommentContent.tsx @@ -3,9 +3,13 @@ import { useTextLimit } from 'hooks' interface CommentContentProps { content: string + isNsfw?: boolean } -export const CommentContent = ({ content }: CommentContentProps) => { +export const CommentContent = ({ + content, + isNsfw = false +}: CommentContentProps) => { const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content) return ( @@ -18,12 +22,17 @@ export const CommentContent = ({ content }: CommentContentProps) => {

Hide full post

)} -

+

-

- {isTextOverflowing && ( +
+ {isTextOverflowing && !isExpanded && (
-

{isExpanded ? 'Hide' : 'View'} full post

+

View full post

+
+ )} + {isNsfw && ( +
+

NSFW

)} diff --git a/src/components/comment/CommentsPopup.tsx b/src/components/comment/CommentsPopup.tsx index d19e127..b7c4d80 100644 --- a/src/components/comment/CommentsPopup.tsx +++ b/src/components/comment/CommentsPopup.tsx @@ -1,5 +1,11 @@ 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 { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react' import { @@ -7,7 +13,9 @@ import { useLoaderData, useLocation, useNavigate, - useParams + useNavigation, + useParams, + useSubmit } from 'react-router-dom' import { appRoutes, @@ -24,8 +32,21 @@ import { Comment } from './Comment' import { useComments } from 'hooks/useComments' import { CommentContent } from './CommentContent' 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 location = useLocation() const { ndk } = useNDKContext() @@ -43,12 +64,14 @@ export const CommentsPopup = () => { ? `${appRoutes.feed}/` : undefined const { event } = useLoaderData() as CommentsLoaderResult + const eTags = event.getMatchingTags('e') + const lastETag = _.last(eTags) const { size, parent: replyEvent, isComplete, root: rootEvent - } = useReplies(event.tagValue('e')) + } = useReplies(lastETag?.[1]) const isRoot = event.tagValue('a') === event.tagValue('A') const [profile, setProfile] = useState() const { commentEvents, setCommentEvents } = useComments( @@ -112,6 +135,80 @@ export const CommentsPopup = () => { setIsSubmitting(false) } + const submit = useSubmit() + const navigation = useNavigation() + const [repostEvents, setRepostEvents] = useState([]) + const [quoteRepostEvents, setQuoteRepostEvents] = useState([]) + 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 (
@@ -119,7 +216,7 @@ export const CommentsPopup = () => {
-

Comment replies

+

{title}

{
- {/*
- - - -

0

-
-
-
-
*/} + {event.kind === NDKKind.Text && ( + <> + {/* Quote Repost, Kind 1 */} +
setShowQuoteRepostPopup(true) + } + > + + + +

+ {isLoading ? : quoteRepostEvents.length} +

+
+
+
+
+ {showQuoteRepostPopup && ( + setShowQuoteRepostPopup(false)} + /> + )} + + {/* Repost, Kind 6 */} +
setShowRepostPopup(true) + } + > + + + +

+ {isLoading ? : repostEvents.length} +

+
+
+
+
+ {showRepostPopup && ( + setShowRepostPopup(false)} + /> + )} + + )} {typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && } diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index b5bc77d..f0349b1 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -82,11 +82,49 @@ export const useComments = ( ) 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) => { - 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] } + // Event is a direct reply return [{ event: ndkEvent }, ...prev] }) }) diff --git a/src/hooks/useScrollDisable.ts b/src/hooks/useScrollDisable.ts index d9658a5..40438a9 100644 --- a/src/hooks/useScrollDisable.ts +++ b/src/hooks/useScrollDisable.ts @@ -2,10 +2,16 @@ import { useEffect } from 'react' export const useBodyScrollDisable = (disable: boolean) => { useEffect(() => { - if (disable) document.body.style.overflow = 'hidden' + const initialOverflow = document.body.style.overflow + + if (disable && initialOverflow !== 'hidden') { + document.body.style.overflow = 'hidden' + } return () => { - document.body.style.overflow = '' + if (initialOverflow !== 'hidden') { + document.body.style.overflow = initialOverflow + } } }, [disable]) } diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 09176cc..19ef011 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -1,40 +1,48 @@ 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, + NDKSubscription, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { NoteSubmit } from 'components/Notes/NoteSubmit' 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 = () => { 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(filterKey, { - ...DEFAULT_FILTER_OPTIONS - }) + const { ndk } = useNDKContext() const [notes, setNotes] = useState([]) + const [discoveredNotes, setDiscoveredNotes] = useState([]) const [isFetching, setIsFetching] = useState(false) const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) const [showing, setShowing] = useState(SHOWING_STEP) + const filterKey = 'filter-feed-2' + const [filterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + useEffect(() => { if (!userPubkey) return setIsFetching(true) setIsLoadMoreVisible(true) + let sub: NDKSubscription + const filter: NDKFilter = { authors: [...followList, userPubkey], kinds: [NDKKind.Text, NDKKind.Repost], @@ -47,60 +55,98 @@ export const FeedTabPosts = () => { cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }) .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) + 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(() => { setIsFetching(false) }) + + return () => { + if (sub) sub.stop() + } }, [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') - ) - ) + // TODO: Enable source/client filter + // _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').length === 0) + _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.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 const handleLoadMore = () => { const LOAD_MORE_STEP = SHOWING_STEP * 2 setShowing((prev) => prev + SHOWING_STEP) - const lastNote = filteredNotes[filteredNotes.length - 1] + const lastNote = notes[notes.length - 1] const filter: NDKFilter = { authors: [...followList, userPubkey], kinds: [NDKKind.Text, NDKKind.Repost], - limit: LOAD_MORE_STEP + limit: LOAD_MORE_STEP, + until: lastNote.created_at } - filter.until = lastNote.created_at - setIsFetching(true) ndk .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 ( <> +
+ +
{isFetching && } {filteredNotes.length === 0 && !isFetching && (
diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts index d87f6fb..7fc4fc9 100644 --- a/src/pages/feed/action.ts +++ b/src/pages/feed/action.ts @@ -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 { ActionFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { getFeedNotePageRoute } from 'routes' import { store } from 'store' -import { NoteSubmitForm, NoteSubmitFormErrors } from 'types' -import { log, LogType, now } from 'utils' +import { + NoteAction, + NoteSubmitForm, + NoteSubmitFormErrors, + TimeoutError +} from 'types' +import { + log, + LogType, + NOTE_DRAFT_CACHE_KEY, + now, + removeLocalStorageItem, + timeout +} from 'utils' export const feedPostRouteAction = (ndkContext: NDKContextType) => @@ -32,39 +44,36 @@ export const feedPostRouteAction = return null } - const formSubmit = (await request.json()) as NoteSubmitForm - 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 - }) - + let action: NoteAction | undefined 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() - const note1 = ndkEvent.encode() - 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)) + case 'repost': + return await handleActionRepost( + ndkContext.ndk, + action.data, + action.note1 + ) + + default: + throw new Error('Unsupported feed action. Intent missing.') } } 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) toast.error('Failed to publish note') return null @@ -80,3 +89,61 @@ const validateFormData = (formSubmit: NoteSubmitForm): NoteSubmitFormErrors => { 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)) + } +} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 478257c..b1ff7e7 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -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 { ModCard } from 'components/ModCard' import { ModFilter } from 'components/Filters/ModsFilter' @@ -19,10 +24,12 @@ import { toast } from 'react-toastify' import { appRoutes } from 'routes' import { BlogCardDetails, + FeedPostsFilter, FilterOptions, ModDetails, ModeratedFilter, NSFWFilter, + RepostFilter, SortBy, UserRelaysType } from 'types' @@ -42,6 +49,8 @@ import { CheckboxField } from 'components/Inputs' import { ProfilePageLoaderResult } from './loader' import { BlogCard } from 'components/BlogCard' import { BlogsFilter } from 'components/Filters/BlogsFilter' +import { FeedFilter } from 'components/Filters/FeedFilter' +import { Note } from 'components/Notes/Note' export const ProfilePage = () => { const { @@ -451,7 +460,7 @@ export const ProfilePage = () => { )} {tab === 1 && } - {tab === 2 && <>WIP} + {tab === 2 && }
@@ -870,3 +879,133 @@ const ProfileTabBlogs = () => { ) } + +const ProfileTabPosts = () => { + const SHOWING_STEP = 20 + const { profilePubkey } = useLoaderData() as ProfilePageLoaderResult + const { ndk } = useNDKContext() + const [notes, setNotes] = useState([]) + const [isFetching, setIsFetching] = useState(false) + const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) + const [showing, setShowing] = useState(SHOWING_STEP) + + const filterKey = 'filter-feed-2' + const [filterOptions] = useLocalStorage( + 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 ( + <> + + {isFetching && } + {filteredNotes.length === 0 && !isFetching && ( +
+

There are no posts to show

+
+ )} +
+
+ {filteredNotes.map((note) => ( + + ))} +
+
+ {!isFetching && isLoadMoreVisible && filteredNotes.length > 0 && ( +
+ +
+ )} + + ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bd90666..a5ed0a4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -109,7 +109,7 @@ export const routerWithNdkContext = (context: NDKContextType) => children: [ { path: ':nevent', - element: , + element: , loader: commentsLoader(context) } ], @@ -136,7 +136,7 @@ export const routerWithNdkContext = (context: NDKContextType) => children: [ { path: ':nevent', - element: , + element: , loader: commentsLoader(context) } ], @@ -209,7 +209,7 @@ export const routerWithNdkContext = (context: NDKContextType) => children: [ { path: ':note', - element: , + element: , loader: commentsLoader(context) } ] diff --git a/src/styles/comments.css b/src/styles/comments.css index 9a47092..3fa7e13 100644 --- a/src/styles/comments.css +++ b/src/styles/comments.css @@ -44,6 +44,7 @@ } .IBMSMSMBSSCL_CommentBottom { + position: relative; width: 100%; padding: 20px; color: rgba(255, 255, 255, 0.75); @@ -64,9 +65,6 @@ .IBMSMSMBSSCL_CBText { white-space: pre-line; - display: flex; - flex-direction: column; - grid-gap: 5px; } .IBMSMSMBSSCL_CBTextStatus { diff --git a/src/types/modsFilter.ts b/src/types/modsFilter.ts index fa5c399..22e0982 100644 --- a/src/types/modsFilter.ts +++ b/src/types/modsFilter.ts @@ -40,3 +40,5 @@ export interface FilterOptions { wot: WOTFilterOptions repost: RepostFilter } + +export type FeedPostsFilter = Pick diff --git a/src/types/note.ts b/src/types/note.ts index 5e2f179..a6c6204 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -1,6 +1,29 @@ +import { NostrEvent } from '@nostr-dev-kit/ndk' + export interface NoteSubmitForm { content: string nsfw: boolean } export interface NoteSubmitFormErrors extends Partial {} + +export type NoteAction = + | { + intent: 'submit' + data: NoteSubmitForm + } + | { + intent: 'repost' + note1: string + data: NostrEvent + } + +export type NoteSubmitActionResult = + | { + type: 'timeout' + action: NoteAction + } + | { + type: 'validation' + formErrors: NoteSubmitFormErrors + } diff --git a/src/utils/comments.ts b/src/utils/comments.ts index 74c4438..db9668b 100644 --- a/src/utils/comments.ts +++ b/src/utils/comments.ts @@ -104,3 +104,5 @@ export function handleCommentSubmit( } } } + +export const NOTE_DRAFT_CACHE_KEY = 'draft-note' diff --git a/src/utils/url.ts b/src/utils/url.ts index c64815c..058ce09 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -60,6 +60,11 @@ export const isValidImageUrl = (url: string) => { 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) => { try { const response = await fetch(url, { method: 'HEAD' })