From b71993adaa413ab8fd3b4aeaaf37ebe2a87e1560 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:00:33 +0100 Subject: [PATCH 01/23] fix(css): remove duplicate a link style --- src/index.css | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/index.css b/src/index.css index 0f79a1a..f749730 100644 --- a/src/index.css +++ b/src/index.css @@ -34,15 +34,6 @@ body { box-sizing: border-box; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - /* h1 { font-size: 3.2em; From 89d7131f7d0043e1a0dd0bc2faa8284900c51427 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:00:59 +0100 Subject: [PATCH 02/23] feat(alert): expand alert popup func --- src/components/AlertPopup.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/AlertPopup.tsx b/src/components/AlertPopup.tsx index e9334ee..2216d13 100644 --- a/src/components/AlertPopup.tsx +++ b/src/components/AlertPopup.tsx @@ -1,12 +1,16 @@ import { createPortal } from 'react-dom' +import { PropsWithChildren } from 'react' import { AlertPopupProps } from 'types' export const AlertPopup = ({ header, label, handleConfirm, - handleClose -}: AlertPopupProps) => { + handleClose, + yesButtonLabel = 'Yes', + noButtonLabel = 'No', + children +}: PropsWithChildren) => { return createPortal(
@@ -38,6 +42,7 @@ export const AlertPopup = ({ > {label} + {children}
handleConfirm(true)} > - Yes + {yesButtonLabel}
From e14422fe68da48fc1a8b512925708ae786721714 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:01:51 +0100 Subject: [PATCH 03/23] feat(util): add middle truncate util function for npubs --- src/utils/utils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 5f75773..6936df9 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -273,3 +273,9 @@ export const normalizeUserSearchString = (str: string): string => { str = removeAccents(str) return str } + +export const truncate = (npub: string): string => { + const start = npub.substring(0, 4) + const end = npub.substring(npub.length - 4, npub.length) + return start + '…' + end +} From df12de3f810f697dba38d6495135659c00f20b48 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:02:38 +0100 Subject: [PATCH 04/23] refactor(hooks): include useTextLimit hook in index file --- src/hooks/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b397cf6..28d616d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,3 +12,4 @@ export * from './useLocalStorage' export * from './useSessionStorage' export * from './useLocalCache' export * from './useReplies' +export * from './useTextLimit' From c6da42caf077e78b1f488c8fc9127f64b7e6f14f Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:04:13 +0100 Subject: [PATCH 05/23] feat(comments): accept nevent and note in commentsLoader --- src/loaders/comment.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/loaders/comment.ts b/src/loaders/comment.ts index 06fa85c..bf82994 100644 --- a/src/loaders/comment.ts +++ b/src/loaders/comment.ts @@ -6,15 +6,19 @@ import { log, LogType } from 'utils' export const commentsLoader = (ndkContext: NDKContextType) => async ({ params }: LoaderFunctionArgs) => { - const { nevent } = params - - if (!nevent) { - log(true, LogType.Error, 'Required nevent.') + const { nevent, note } = params + const target = nevent || note + if (!target) { + log( + true, + LogType.Error, + 'Missing event parameter in the URL (nevent, note).' + ) return redirect('..') } try { - const replyEvent = await ndkContext.ndk.fetchEvent(nevent) + const replyEvent = await ndkContext.ndk.fetchEvent(target) if (!replyEvent) { throw new Error('We are unable to find the comment on the relays') From 2545e74aa6ef610295bb8b85c7ec1dd6b0b8a65c Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:04:44 +0100 Subject: [PATCH 06/23] feat(profile): add minimal profile link component --- src/components/ProfileSection.tsx | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 4f4d7b4..d00d289 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -21,7 +21,8 @@ import { log, LogType, now, - npubToHex + npubToHex, + truncate } from '../utils' import { LoadingSpinner } from './LoadingSpinner' import { ZapPopUp } from './Zap' @@ -575,3 +576,33 @@ 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!, { + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + + const displayName = profile?.displayName || profile?.name || truncate(npub) + return @{displayName} +} From ed42f481e6cc4d1fc209eb44788d29045ab59cc9 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:05:59 +0100 Subject: [PATCH 07/23] feat(comments): accept note from feed in commentspopup --- src/components/comment/CommentsPopup.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/comment/CommentsPopup.tsx b/src/components/comment/CommentsPopup.tsx index 527261d..662923b 100644 --- a/src/components/comment/CommentsPopup.tsx +++ b/src/components/comment/CommentsPopup.tsx @@ -9,7 +9,12 @@ import { useNavigate, useParams } from 'react-router-dom' -import { getBlogPageRoute, getModPageRoute, getProfilePageRoute } from 'routes' +import { + appRoutes, + getBlogPageRoute, + getModPageRoute, + getProfilePageRoute +} from 'routes' import { CommentEvent, UserProfile } from 'types' import { CommentsLoaderResult } from 'types/comments' import { adjustTextareaHeight, handleCommentSubmit, hexToNpub } from 'utils' @@ -28,11 +33,14 @@ export const CommentsPopup = () => { useBodyScrollDisable(true) const isMod = location.pathname.includes('/mod/') const isBlog = location.pathname.includes('/blog/') + const isNote = location.pathname.includes('/feed/') const baseUrl = naddr ? isMod ? getModPageRoute(naddr) : isBlog ? getBlogPageRoute(naddr) + : isNote + ? appRoutes.feed : undefined : undefined From f8e770d952ffc86a9d2ca2ffc1c06ccf75e49078 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:07:11 +0100 Subject: [PATCH 08/23] fix(alert): add alert popup types --- src/types/popup.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types/popup.ts b/src/types/popup.ts index b1551cf..ca79618 100644 --- a/src/types/popup.ts +++ b/src/types/popup.ts @@ -6,4 +6,6 @@ export interface AlertPopupProps extends PopupProps { header: string label: string handleConfirm: (confirm: boolean) => void + yesButtonLabel?: string | undefined + noButtonLabel?: string | undefined } From eb27eabcc659398f821ae14528ed58d294831b76 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:08:03 +0100 Subject: [PATCH 09/23] refactor(feed): show users mods and blogs in feed --- src/pages/feed/FeedTabBlogs.tsx | 157 ++++++++++++++++---------------- src/pages/feed/FeedTabMods.tsx | 113 ++++++++++++----------- 2 files changed, 138 insertions(+), 132 deletions(-) diff --git a/src/pages/feed/FeedTabBlogs.tsx b/src/pages/feed/FeedTabBlogs.tsx index d7f762e..371abc3 100644 --- a/src/pages/feed/FeedTabBlogs.tsx +++ b/src/pages/feed/FeedTabBlogs.tsx @@ -29,12 +29,91 @@ export const FeedTabBlogs = () => { const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) const [showing, setShowing] = useState(SHOWING_STEP) + useEffect(() => { + if (!userPubkey) return + + setIsFetching(true) + setIsLoadMoreVisible(true) + const filter: NDKFilter = { + authors: [...followList, userPubkey], + kinds: [NDKKind.Article], + limit: 50 + } + + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + filter['#L'] = ['content-warning'] + } + + if (filterOptions.source === window.location.host) { + filter['#r'] = [window.location.host] + } + + ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + setBlogs(ndkEvents.map(extractBlogCardDetails)) + }) + .finally(() => { + setIsFetching(false) + }) + }, [filterOptions.nsfw, filterOptions.source, followList, ndk, userPubkey]) + + const filteredBlogs = useMemo(() => { + let _blogs = blogs || [] + + // Add nsfw tag to blogs included in nsfwList + if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) { + _blogs = _blogs.map((b) => { + return !b.nsfw && b.aTag && nsfwList.includes(b.aTag) + ? { ...b, nsfw: true } + : b + }) + } + // Filter nsfw (Hide_NSFW option) + _blogs = _blogs.filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + + _blogs = _blogs.filter( + (b) => + !muteLists.admin.authors.includes(b.author!) && + !muteLists.admin.replaceableEvents.includes(b.aTag!) + ) + + if (filterOptions.sort === SortBy.Latest) { + _blogs.sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + } else if (filterOptions.sort === SortBy.Oldest) { + _blogs.sort((a, b) => + a.published_at && b.published_at ? a.published_at - b.published_at : 0 + ) + } + + showing > 0 && _blogs.splice(showing) + return _blogs + }, [ + blogs, + filterOptions.nsfw, + filterOptions.sort, + muteLists.admin.authors, + muteLists.admin.replaceableEvents, + nsfwList, + showing + ]) + + if (!userPubkey) return null + const handleLoadMore = () => { const LOAD_MORE_STEP = SHOWING_STEP * 2 setShowing((prev) => prev + SHOWING_STEP) const lastBlog = filteredBlogs[filteredBlogs.length - 1] const filter: NDKFilter = { - authors: [...followList], + authors: [...followList, userPubkey], kinds: [NDKKind.Article], limit: LOAD_MORE_STEP } @@ -84,82 +163,6 @@ export const FeedTabBlogs = () => { }) } - useEffect(() => { - setIsFetching(true) - setIsLoadMoreVisible(true) - const filter: NDKFilter = { - authors: [...followList], - kinds: [NDKKind.Article], - limit: 50 - } - - if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { - filter['#L'] = ['content-warning'] - } - - if (filterOptions.source === window.location.host) { - filter['#r'] = [window.location.host] - } - - ndk - .fetchEvents(filter, { - closeOnEose: true, - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL - }) - .then((ndkEventSet) => { - const ndkEvents = Array.from(ndkEventSet) - setBlogs(ndkEvents.map(extractBlogCardDetails)) - }) - .finally(() => { - setIsFetching(false) - }) - }, [filterOptions.nsfw, filterOptions.source, followList, ndk]) - - const filteredBlogs = useMemo(() => { - let _blogs = blogs || [] - - // Add nsfw tag to blogs included in nsfwList - if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) { - _blogs = _blogs.map((b) => { - return !b.nsfw && b.aTag && nsfwList.includes(b.aTag) - ? { ...b, nsfw: true } - : b - }) - } - // Filter nsfw (Hide_NSFW option) - _blogs = _blogs.filter( - (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) - ) - - _blogs = _blogs.filter( - (b) => - !muteLists.admin.authors.includes(b.author!) && - !muteLists.admin.replaceableEvents.includes(b.aTag!) - ) - - if (filterOptions.sort === SortBy.Latest) { - _blogs.sort((a, b) => - a.published_at && b.published_at ? b.published_at - a.published_at : 0 - ) - } else if (filterOptions.sort === SortBy.Oldest) { - _blogs.sort((a, b) => - a.published_at && b.published_at ? a.published_at - b.published_at : 0 - ) - } - - showing > 0 && _blogs.splice(showing) - return _blogs - }, [ - blogs, - filterOptions.nsfw, - filterOptions.sort, - muteLists.admin.authors, - muteLists.admin.replaceableEvents, - nsfwList, - showing - ]) - - if (!userPubkey) return null return ( <> {isFetching && ( diff --git a/src/pages/feed/FeedTabMods.tsx b/src/pages/feed/FeedTabMods.tsx index c4f6932..0e42527 100644 --- a/src/pages/feed/FeedTabMods.tsx +++ b/src/pages/feed/FeedTabMods.tsx @@ -39,64 +39,13 @@ export const FeedTabMods = () => { const [isLoadMoreVisible, setIsLoadMoreVisible] = useState(true) const [showing, setShowing] = useState(SHOWING_STEP) - const handleLoadMore = () => { - const LOAD_MORE_STEP = SHOWING_STEP * 2 - setShowing((prev) => prev + SHOWING_STEP) - const lastMod = filteredModList[filteredModList.length - 1] - const filter: NDKFilter = { - authors: [...followList], - kinds: [NDKKind.Classified], - limit: LOAD_MORE_STEP - } - - if (filterOptions.source === window.location.host) { - filter['#r'] = [window.location.host] - } - - if (filterOptions.sort === SortBy.Latest) { - filter.until = lastMod.published_at - } else if (filterOptions.sort === SortBy.Oldest) { - filter.since = lastMod.published_at - } - - setIsFetching(true) - ndk - .fetchEvents(filter, { - closeOnEose: true, - cacheUsage: NDKSubscriptionCacheUsage.PARALLEL - }) - .then((ndkEventSet) => { - const ndkEvents = Array.from(ndkEventSet) - orderEventsChronologically( - ndkEvents, - filterOptions.sort === SortBy.Latest - ) - setMods((prevMods) => { - const newMods = constructModListFromEvents(ndkEvents) - const combinedMods = [...prevMods, ...newMods] - const uniqueMods = Array.from( - new Set(combinedMods.map((mod) => mod.id)) - ) - .map((id) => combinedMods.find((mod) => mod.id === id)) - .filter((mod): mod is ModDetails => mod !== undefined) - - if (newMods.length < LOAD_MORE_STEP) { - setIsLoadMoreVisible(false) - } - - return uniqueMods - }) - }) - .finally(() => { - setIsFetching(false) - }) - } - useEffect(() => { + if (!userPubkey) return + setIsFetching(true) setIsLoadMoreVisible(true) const filter: NDKFilter = { - authors: [...followList], + authors: [...followList, userPubkey], kinds: [NDKKind.Classified], limit: 50 } @@ -117,7 +66,7 @@ export const FeedTabMods = () => { .finally(() => { setIsFetching(false) }) - }, [filterOptions.source, followList, ndk]) + }, [filterOptions.source, followList, ndk, userPubkey]) const filteredModList = useMemo(() => { const nsfwFilter = (mods: ModDetails[]) => { @@ -198,6 +147,60 @@ export const FeedTabMods = () => { ]) if (!userPubkey) return null + + const handleLoadMore = () => { + const LOAD_MORE_STEP = SHOWING_STEP * 2 + setShowing((prev) => prev + SHOWING_STEP) + const lastMod = filteredModList[filteredModList.length - 1] + const filter: NDKFilter = { + authors: [...followList, userPubkey], + kinds: [NDKKind.Classified], + limit: LOAD_MORE_STEP + } + + if (filterOptions.source === window.location.host) { + filter['#r'] = [window.location.host] + } + + if (filterOptions.sort === SortBy.Latest) { + filter.until = lastMod.published_at + } else if (filterOptions.sort === SortBy.Oldest) { + filter.since = lastMod.published_at + } + + setIsFetching(true) + ndk + .fetchEvents(filter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) + .then((ndkEventSet) => { + const ndkEvents = Array.from(ndkEventSet) + orderEventsChronologically( + ndkEvents, + filterOptions.sort === SortBy.Latest + ) + setMods((prevMods) => { + const newMods = constructModListFromEvents(ndkEvents) + const combinedMods = [...prevMods, ...newMods] + const uniqueMods = Array.from( + new Set(combinedMods.map((mod) => mod.id)) + ) + .map((id) => combinedMods.find((mod) => mod.id === id)) + .filter((mod): mod is ModDetails => mod !== undefined) + + if (newMods.length < LOAD_MORE_STEP) { + setIsLoadMoreVisible(false) + } + + return uniqueMods + }) + }) + .finally(() => { + setIsFetching(false) + }) + } + return ( <> {isFetching && } From 27cd22f47b5b70c41e00862e042de91407a7fa3e Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:11:12 +0100 Subject: [PATCH 10/23] feat(feed): add feed outlet, note action and types --- src/pages/feed/action.ts | 85 ++++++++++++++++++++++++++++++++++++++++ src/pages/feed/index.tsx | 7 +++- src/routes/index.tsx | 15 ++++++- src/types/index.ts | 1 + src/types/note.ts | 6 +++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/pages/feed/action.ts create mode 100644 src/types/note.ts diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts new file mode 100644 index 0000000..1905c2c --- /dev/null +++ b/src/pages/feed/action.ts @@ -0,0 +1,85 @@ +import { NDKEvent, NDKKind } 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' + +export const feedPostRouteAction = + (ndkContext: NDKContextType) => + async ({ request }: ActionFunctionArgs) => { + const userState = store.getState().user + let hexPubkey: string + if (userState.auth && userState.user?.pubkey) { + hexPubkey = userState.user.pubkey as string + } else { + try { + hexPubkey = (await window.nostr?.getPublicKey()) as string + } catch (error) { + if (error instanceof Error) { + log(true, LogType.Error, 'Failed to get public key.', error) + } + + toast.error('Failed to get public key.') + return null + } + } + + if (!hexPubkey) { + toast.error('Could not get pubkey') + 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: [], + pubkey: hexPubkey + }) + + try { + await ndkEvent.generateTags() + + if (formSubmit.nsfw) ndkEvent.tags.push(['L', 'content-warning']) + + ndkEvent.tags.push(['L', 'source']) + ndkEvent.tags.push(['l', window.location.host, 'source']) + ndkEvent.tags.push(['client', 'DEG Mods']) + + 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)) + } + } catch (error) { + log(true, LogType.Error, 'Failed to publish note', error) + toast.error('Failed to publish note') + return null + } + } + +const validateFormData = (formSubmit: NoteSubmitForm): NoteSubmitFormErrors => { + const errors: NoteSubmitFormErrors = {} + + if (!formSubmit.content.trim()) { + errors.content = 'Content is required' + } + + return errors +} diff --git a/src/pages/feed/index.tsx b/src/pages/feed/index.tsx index a2bef4c..1cc3d65 100644 --- a/src/pages/feed/index.tsx +++ b/src/pages/feed/index.tsx @@ -4,9 +4,12 @@ import { FeedTabBlogs } from './FeedTabBlogs' import { FeedTabMods } from './FeedTabMods' import { FeedTabPosts } from './FeedTabPosts' import { FeedFilter } from 'components/Filters/FeedFilter' +import { Outlet, useParams } from 'react-router-dom' export const FeedPage = () => { - const [tab, setTab] = useState(0) + const { note } = useParams() + // Open posts tab if note is present + const [tab, setTab] = useState(note ? 2 : 0) return ( <> @@ -17,6 +20,8 @@ export const FeedPage = () => { {tab === 0 && } {tab === 1 && } {tab === 2 && } + + ) } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 066c487..bd90666 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -33,6 +33,7 @@ import { BackupPage } from 'pages/backup' import { SupportersPage } from 'pages/supporters' import { commentsLoader } from 'loaders/comment' import { CommentsPopup } from 'components/comment/CommentsPopup' +import { feedPostRouteAction } from 'pages/feed/action' export const appRoutes = { home: '/', @@ -56,6 +57,7 @@ export const appRoutes = { settingsAdmin: '/settings-admin', profile: '/profile/:nprofile?', feed: '/feed', + note: '/feed/:note', notifications: '/notifications', backup: '/backup', supporters: '/supporters' @@ -76,6 +78,9 @@ export const getBlogPageRoute = (eventId: string) => export const getProfilePageRoute = (nprofile: string) => appRoutes.profile.replace(':nprofile', nprofile) +export const getFeedNotePageRoute = (note: string) => + appRoutes.note.replace(':note', note) + export const routerWithNdkContext = (context: NDKContextType) => createBrowserRouter([ { @@ -199,7 +204,15 @@ export const routerWithNdkContext = (context: NDKContextType) => { path: appRoutes.feed, element: , - loader: feedPageLoader(context) + loader: feedPageLoader(context), + action: feedPostRouteAction(context), + children: [ + { + path: ':note', + element: , + loader: commentsLoader(context) + } + ] }, { path: appRoutes.notifications, diff --git a/src/types/index.ts b/src/types/index.ts index 431b336..3d4c6f5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,3 +8,4 @@ export * from './category' export * from './popup' export * from './errors' export * from './comments' +export * from './note' diff --git a/src/types/note.ts b/src/types/note.ts new file mode 100644 index 0000000..5e2f179 --- /dev/null +++ b/src/types/note.ts @@ -0,0 +1,6 @@ +export interface NoteSubmitForm { + content: string + nsfw: boolean +} + +export interface NoteSubmitFormErrors extends Partial {} From 74880acadde93ce6c1898ba7fe94d290250a0351 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:11:53 +0100 Subject: [PATCH 11/23] feat(comment): show sticky hide full button on top --- src/components/comment/CommentContent.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/comment/CommentContent.tsx b/src/components/comment/CommentContent.tsx index 4b6db38..1657d89 100644 --- a/src/components/comment/CommentContent.tsx +++ b/src/components/comment/CommentContent.tsx @@ -1,13 +1,26 @@ -import { useTextLimit } from 'hooks/useTextLimit' +import { NoteRender } from 'components/Notes/NoteRender' +import { useTextLimit } from 'hooks' + interface CommentContentProps { content: string } + export const CommentContent = ({ content }: CommentContentProps) => { const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content) return ( <> -

{text}

+ {isExpanded && ( +
+

Hide full post

+
+ )} +

+ +

{isTextOverflowing && (

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

From 7c3bf7d76a0f6672ff79508b691df9983e522c89 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 14 Feb 2025 13:15:10 +0100 Subject: [PATCH 12/23] 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 ( + <> +
+
+
+
+
+
+
+
+