From 7c3bf7d76a0f6672ff79508b691df9983e522c89 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:15:10 +0100 Subject: [PATCH] feat(notes): notes feed, render, preview, submit --- src/components/Notes/Note.tsx | 317 ++++++++++++++++++ src/components/Notes/NotePreview.tsx | 23 ++ src/components/Notes/NoteQuoteRepostPopup.tsx | 1 + src/components/Notes/NoteRender.tsx | 86 +++++ src/components/Notes/NoteRepost.tsx | 117 +++++++ src/components/Notes/NoteSubmit.tsx | 121 +++++++ src/components/Notes/internal/BlogPreview.tsx | 25 ++ src/components/Notes/internal/ModPreview.tsx | 25 ++ src/components/Notes/internal/NoteWrapper.tsx | 77 +++++ src/pages/feed/FeedTabPosts.tsx | 165 ++++++++- 10 files changed, 956 insertions(+), 1 deletion(-) create mode 100644 src/components/Notes/Note.tsx create mode 100644 src/components/Notes/NotePreview.tsx create mode 100644 src/components/Notes/NoteQuoteRepostPopup.tsx create mode 100644 src/components/Notes/NoteRender.tsx create mode 100644 src/components/Notes/NoteRepost.tsx create mode 100644 src/components/Notes/NoteSubmit.tsx create mode 100644 src/components/Notes/internal/BlogPreview.tsx create mode 100644 src/components/Notes/internal/ModPreview.tsx create mode 100644 src/components/Notes/internal/NoteWrapper.tsx diff --git a/src/components/Notes/Note.tsx b/src/components/Notes/Note.tsx new file mode 100644 index 0000000..efbc430 --- /dev/null +++ b/src/components/Notes/Note.tsx @@ -0,0 +1,317 @@ +import { + NDKEvent, + NDKFilter, + NDKKind, + NDKSubscriptionCacheUsage +} from '@nostr-dev-kit/ndk' +import { AlertPopup } from 'components/AlertPopup' +import { CommentContent } from 'components/comment/CommentContent' +import { Reactions } from 'components/comment/Reactions' +import { Zap } from 'components/comment/Zap' +import { Dots } from 'components/Spinner' +import { formatDate } from 'date-fns' +import { useAppSelector, useDidMount, useNDKContext } from 'hooks' +import { useComments } from 'hooks/useComments' +import { nip19 } from 'nostr-tools' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { appRoutes, getProfilePageRoute } from 'routes' +import { UserProfile } from 'types' +import { hexToNpub } from 'utils' +import { NoteSubmit } from './NoteSubmit' +import { NoteRepost } from './NoteRepost' + +interface NoteProps { + ndkEvent: NDKEvent +} + +export const Note = ({ ndkEvent }: NoteProps) => { + const { ndk } = useNDKContext() + const userState = useAppSelector((state) => state.user) + const userPubkey = userState.user?.pubkey as string | undefined + const [eventProfile, setEventProfile] = useState() + const isRepost = ndkEvent.kind === NDKKind.Repost + const [repostEvent, setRepostEvent] = useState() + const [repostProfile, setRepostProfile] = useState() + const noteEvent = repostEvent ?? ndkEvent + const noteProfile = repostProfile ?? eventProfile + const { commentEvents } = useComments(ndkEvent.pubkey, undefined, ndkEvent.id) + const [quoteRepostEvents, setQuoteRepostEvents] = useState([]) + const [hasQuoted, setHasQuoted] = useState(false) + const [repostEvents, setRepostEvents] = useState([]) + const [hasReposted, setHasReposted] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [showRepostPopup, setShowRepostPopup] = useState(false) + const [showQuoteRepostPopup, setShowQuoteRepostPopup] = useState(false) + + useDidMount(() => { + setIsLoading(true) + ndkEvent.author.fetchProfile().then((res) => setEventProfile(res)) + + if (isRepost) { + const parsedEvent = JSON.parse(ndkEvent.content) + const ndkRepostEvent = new NDKEvent(ndk, parsedEvent) + setRepostEvent(ndkRepostEvent) + ndkRepostEvent.author.fetchProfile().then((res) => setRepostProfile(res)) + } + + const repostFilter: NDKFilter = { + kinds: [NDKKind.Repost], + '#e': [ndkEvent.id] + } + const quoteFilter: NDKFilter = { + kinds: [NDKKind.Text], + '#q': [ndkEvent.id] + } + ndk + .fetchEvents([repostFilter, quoteFilter], { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + + if (ndkEventSet.size) { + const quoteRepostEvents = ndkEvents.filter( + (n) => n.kind === NDKKind.Text + ) + userPubkey && + setHasQuoted( + quoteRepostEvents.some((qr) => qr.pubkey === userPubkey) + ) + setQuoteRepostEvents(quoteRepostEvents) + + const repostEvents = ndkEvents.filter( + (n) => n.kind === NDKKind.Repost + ) + userPubkey && + setHasReposted(repostEvents.some((qr) => qr.pubkey === userPubkey)) + setRepostEvents(repostEvents) + } + }) + .finally(() => { + setIsLoading(false) + }) + }) + + const profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: noteEvent.pubkey + }) + ) + + const reposterRoute = repostEvent + ? getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: ndkEvent.pubkey + }) + ) + : undefined + + const baseUrl = appRoutes.feed + '/' + + // Did user already repost this + + // Show who reposted the note + const reposterVisual = + repostEvent && reposterRoute ? ( + <> +
+
+ + + +
+

+ + {eventProfile?.displayName || eventProfile?.name || ''}{' '} + + Reposted... +

+
+ + ) : null + + const handleQuoteRepost = async (confirm: boolean) => { + setShowQuoteRepostPopup(false) + + if (!confirm) return + } + + const handleRepost = async (confirm: boolean) => { + setShowRepostPopup(false) + + // Cancel if not confirmed + if (!confirm) return + + const repostNdkEvent = await ndkEvent.repost(false) + await repostNdkEvent.sign() + } + + // Is this user's repost? + const isUsersRepost = + isRepost && ndkEvent.author.pubkey === userState.user?.pubkey + + return ( +
+
+ {reposterVisual} +
+
+ +
+
+
+ + {noteProfile?.displayName || noteProfile?.name || ''}{' '} + + + {hexToNpub(noteEvent.pubkey)} + +
+ {noteEvent.created_at && ( + + )} +
+
+
+ +
+
+
+ + {/* Quote Repost, Kind 1 */} +
setShowQuoteRepostPopup(true) + } + > + + + +

+ {isLoading ? : quoteRepostEvents.length} +

+
+
+
+
+ {/* TODO: new popup */} + {showQuoteRepostPopup && ( + setShowQuoteRepostPopup(false)} + header={'Quote Repost'} + label={``} + yesButtonLabel='Post' + noButtonLabel='Cancel' + > + + + )} + {/* Repost, Kind 6 */} + + {!isUsersRepost && ( +
setShowRepostPopup(true) + } + > + + + +

+ {isLoading ? : repostEvents.length} +

+
+
+
+
+ )} + {showRepostPopup && ( + setShowRepostPopup(false)} + /> + )} + {typeof noteProfile?.lud16 !== 'undefined' && + noteProfile.lud16 !== '' && } + + + + +

+ {commentEvents.length} +

+

Replies

+ + +

Reply

+ +
+
+
+
+ ) +} diff --git a/src/components/Notes/NotePreview.tsx b/src/components/Notes/NotePreview.tsx new file mode 100644 index 0000000..2b16ece --- /dev/null +++ b/src/components/Notes/NotePreview.tsx @@ -0,0 +1,23 @@ +import { NoteRender } from './NoteRender' + +interface NotePreviewProps { + content: string +} + +export const NotePreview = ({ content }: NotePreviewProps) => { + return ( +
+
+

+ Previewing post +
+

+
+
+
+ +
+
+
+ ) +} diff --git a/src/components/Notes/NoteQuoteRepostPopup.tsx b/src/components/Notes/NoteQuoteRepostPopup.tsx new file mode 100644 index 0000000..2169d22 --- /dev/null +++ b/src/components/Notes/NoteQuoteRepostPopup.tsx @@ -0,0 +1 @@ +//TODO: quote repost popup diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx new file mode 100644 index 0000000..7ad2e23 --- /dev/null +++ b/src/components/Notes/NoteRender.tsx @@ -0,0 +1,86 @@ +import { NDKKind } from '@nostr-dev-kit/ndk' +import { ProfileLink } from 'components/ProfileSection' +import { nip19 } from 'nostr-tools' +import { useMemo } from 'react' +import { Fragment } from 'react/jsx-runtime' +import { BlogPreview } from './internal/BlogPreview' +import { ModPreview } from './internal/ModPreview' +import { NoteWrapper } from './internal/NoteWrapper' + +interface NoteRenderProps { + content: string +} +const link = + /(?:https?:\/\/|www\.)(?:[a-zA-Z0-9.-]+\.[a-zA-Z]+(?::\d+)?)(?:[/?#][\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]*)?/gu +const nostrMention = + /(?:nostr:|@)?(?:npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi +const nostrEntity = + /(npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,}/gi + +export const NoteRender = ({ content }: NoteRenderProps) => { + const _content = useMemo(() => { + if (!content) return + + const parts = content.split( + new RegExp(`(${link.source})|(${nostrMention.source})`, 'gui') + ) + + const _parts = parts.map((part, index) => { + if (link.test(part)) { + const [href] = part.match(link) || [] + return ( + + {href} + + ) + } else if (nostrMention.test(part)) { + const [encoded] = part.match(nostrEntity) || [] + + if (!encoded) return part + + try { + const decoded = nip19.decode(encoded) + + switch (decoded.type) { + case 'nprofile': + return + case 'npub': + return + case 'note': + return + case 'nevent': + return + case 'naddr': + return ( + + {handleNaddr(decoded.data, part)} + + ) + + default: + return part + } + } catch (error) { + return part + } + } else { + return part + } + }) + return _parts + }, [content]) + + return _content +} + +function handleNaddr(data: nip19.AddressPointer, original: string) { + const { kind } = data + + if (kind === NDKKind.Article) { + return + } else if (kind === NDKKind.Classified) { + return + } else { + return <>{original} + } +} diff --git a/src/components/Notes/NoteRepost.tsx b/src/components/Notes/NoteRepost.tsx new file mode 100644 index 0000000..d24b82b --- /dev/null +++ b/src/components/Notes/NoteRepost.tsx @@ -0,0 +1,117 @@ +import { AlertPopup } from 'components/AlertPopup' +import { useAppSelector, useDidMount } from 'hooks' +import { useState } from 'react' +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { Link } from 'react-router-dom' +import { CommentContent } from 'components/comment/CommentContent' +import { getProfilePageRoute } from 'routes' +import { nip19 } from 'nostr-tools' +import { UserProfile } from 'types' +import { hexToNpub } from 'utils' +import { formatDate } from 'date-fns' + +interface NoteRepostProps { + ndkEvent: NDKEvent + handleConfirm: (confirm: boolean) => void + handleClose: () => void +} + +export const NoteRepost = ({ + ndkEvent, + handleConfirm, + handleClose +}: NoteRepostProps) => { + const userState = useAppSelector((state) => state.user) + const userPubkey = userState.user?.pubkey as string | undefined + const [content, setContent] = useState('') + const [profile, setProfile] = useState() + + useDidMount(async () => { + const repost = await ndkEvent.repost(false) + setContent(JSON.parse(repost.content).content) + ndkEvent.author.fetchProfile().then((res) => setProfile(res)) + }) + + const profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: ndkEvent.pubkey + }) + ) + + const reposterRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: userPubkey! + }) + ) + + return ( + +
+
+
+
+ + + +
+

+ + {userState.user?.displayName || userState.user?.name || ''}{' '} + + Reposted... +

+
+
+
+ +
+
+
+ + {profile?.displayName || profile?.name || ''}{' '} + + + {hexToNpub(ndkEvent.pubkey)} + +
+ {ndkEvent.created_at && ( + + )} +
+
+
+ +
+
+
+
+ ) +} diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx new file mode 100644 index 0000000..530bf06 --- /dev/null +++ b/src/components/Notes/NoteSubmit.tsx @@ -0,0 +1,121 @@ +import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' +import { FALLBACK_PROFILE_IMAGE } from '../../constants' +import { useAppSelector } from 'hooks' +import { useProfile } from 'hooks/useProfile' +import { Navigate, useSubmit } from 'react-router-dom' +import { appRoutes } from 'routes' +import { useState } from 'react' +import { adjustTextareaHeight } from 'utils' +import { NotePreview } from './NotePreview' + +interface NoteSubmitProps { + initialContent?: string | undefined +} + +export const NoteSubmit = ({ initialContent }: NoteSubmitProps) => { + const userState = useAppSelector((state) => state.user) + const profile = useProfile(userState.user?.pubkey as string | undefined, { + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + const [content, setContent] = useState(initialContent ?? '') + const [nsfw, setNsfw] = useState(false) + const [showPreview, setShowPreview] = useState(false) + const image = profile?.image || FALLBACK_PROFILE_IMAGE + + const submit = useSubmit() + + const handleContentChange = ( + event: React.ChangeEvent + ) => { + setContent(event.currentTarget.value) + adjustTextareaHeight(event.currentTarget) + } + + const handleFormSubmit = async (event: React.FormEvent) => { + event.preventDefault() + const formSubmit = { + content, + nsfw + } + submit(JSON.stringify(formSubmit), { + method: 'post', + encType: 'application/json' + }) + } + + const handlePreviewToggle = () => { + setShowPreview((prev) => !prev) + } + + if (!userState.user?.pubkey) return + + return ( + <> +
+
+
+
+
+
+
+
+