From db5b2d0a9c402cb68d8c099cfccb6d9e21fc9b73 Mon Sep 17 00:00:00 2001 From: en Date: Sat, 15 Feb 2025 14:09:04 +0100 Subject: [PATCH 01/22] fix(popup: double body scroll and remove 2nd hide full post button --- src/components/comment/CommentContent.tsx | 4 ++-- src/hooks/useScrollDisable.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/comment/CommentContent.tsx b/src/components/comment/CommentContent.tsx index 1657d89..bbbfeb2 100644 --- a/src/components/comment/CommentContent.tsx +++ b/src/components/comment/CommentContent.tsx @@ -21,9 +21,9 @@ export const CommentContent = ({ content }: CommentContentProps) => {

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

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

+

View full post

)} 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]) } -- 2.34.1 From 221b8b205094493f2dbf853b4effe5eb2057dc26 Mon Sep 17 00:00:00 2001 From: en Date: Sat, 15 Feb 2025 17:56:20 +0100 Subject: [PATCH 02/22] refactor(feed): comment out source filter in the feed --- src/components/Filters/FeedFilter.tsx | 55 ++++++++++++++------------- src/pages/feed/FeedTabPosts.tsx | 21 +++++----- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/components/Filters/FeedFilter.tsx b/src/components/Filters/FeedFilter.tsx index bdd8cb3..838723c 100644 --- a/src/components/Filters/FeedFilter.tsx +++ b/src/components/Filters/FeedFilter.tsx @@ -47,34 +47,37 @@ export const FeedFilter = React.memo( {/* source filter options */} - - - - + + + + )} {children} diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 09176cc..58bd46f 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -68,15 +68,16 @@ export const FeedTabPosts = () => { ) // 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) @@ -85,7 +86,7 @@ export const FeedTabPosts = () => { showing > 0 && _notes.splice(showing) return _notes - }, [filterOptions.nsfw, filterOptions.source, notes, showing]) + }, [filterOptions.nsfw, notes, showing]) if (!userPubkey) return null -- 2.34.1 From d6672038bbb8edf599cfcc3b580b399f31c95bea Mon Sep 17 00:00:00 2001 From: en Date: Sat, 15 Feb 2025 18:08:53 +0100 Subject: [PATCH 03/22] fix(notes): missing links on date time --- src/components/Notes/Note.tsx | 16 ++++++++++------ src/components/Notes/internal/NoteWrapper.tsx | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/components/Notes/Note.tsx b/src/components/Notes/Note.tsx index eb0ced3..16f709e 100644 --- a/src/components/Notes/Note.tsx +++ b/src/components/Notes/Note.tsx @@ -109,8 +109,6 @@ export const Note = ({ ndkEvent }: NoteProps) => { const baseUrl = appRoutes.feed + '/' - // Did user already repost this - // Show who reposted the note const reposterVisual = repostEvent && reposterRoute ? ( @@ -178,12 +176,18 @@ export const Note = ({ ndkEvent }: NoteProps) => { {noteEvent.created_at && (
- + {formatDate(noteEvent.created_at * 1000, 'hh:mm aa')}{' '} - - + + {formatDate(noteEvent.created_at * 1000, 'dd/MM/yyyy')} - +
)} 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 && ( )}
-- 2.34.1 From b84adf3617505d7a364b5e4a667455d826ddb50e Mon Sep 17 00:00:00 2001 From: en Date: Sat, 15 Feb 2025 18:33:41 +0100 Subject: [PATCH 04/22] fix(notes): nsfw filter --- src/pages/feed/FeedTabPosts.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 58bd46f..869cb26 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -58,14 +58,18 @@ export const FeedTabPosts = () => { 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' - ) - ) + // NSFW Filter + _notes = _notes.filter((n) => { + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + return n.getMatchingTags('L').some((l) => l[1] === 'content-warning') + } + + if (filterOptions.nsfw === NSFWFilter.Hide_NSFW) { + return !n.getMatchingTags('L').some((l) => l[1] === 'content-warning') + } + + return n + }) // Filter source // TODO: Enable source/client filter -- 2.34.1 From e92d602f3a52eeda440a5b4d553980ab883fbb74 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 12:55:44 +0100 Subject: [PATCH 05/22] fix(notes): nsfw filter, nsfw post wrapper --- src/components/Filters/FeedFilter.tsx | 9 ++- src/components/Filters/NsfwFilterOptions.tsx | 57 +++++++++++-------- src/components/Notes/Note.tsx | 32 +++++++++-- src/components/NsfwCommentWrapper.tsx | 60 ++++++++++++++++++++ src/components/comment/CommentContent.tsx | 11 +++- src/pages/feed/FeedTabPosts.tsx | 24 +------- src/styles/comments.css | 1 + src/types/modsFilter.ts | 2 + 8 files changed, 143 insertions(+), 53 deletions(-) create mode 100644 src/components/NsfwCommentWrapper.tsx diff --git a/src/components/Filters/FeedFilter.tsx b/src/components/Filters/FeedFilter.tsx index 838723c..e3718a4 100644 --- a/src/components/Filters/FeedFilter.tsx +++ b/src/components/Filters/FeedFilter.tsx @@ -43,7 +43,14 @@ export const FeedFilter = React.memo( {/* nsfw filter options */} - + {/* source filter options */} 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 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 @@ -192,9 +206,15 @@ export const Note = ({ ndkEvent }: NoteProps) => { )} -
- -
+ +
+ +
+
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/comment/CommentContent.tsx b/src/components/comment/CommentContent.tsx index bbbfeb2..49e7f1b 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 ( @@ -26,6 +30,11 @@ export const CommentContent = ({ content }: CommentContentProps) => {

View full post

)} + {isNsfw && ( +
+

NSFW

+
+ )} ) } diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 869cb26..4fd7e40 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -1,8 +1,6 @@ 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 { useAppSelector, useNDKContext } from 'hooks' import { useEffect, useMemo, useState } from 'react' import { LoadingSpinner } from 'components/LoadingSpinner' import { @@ -19,10 +17,7 @@ export const FeedTabPosts = () => { 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 [isFetching, setIsFetching] = useState(false) @@ -58,19 +53,6 @@ export const FeedTabPosts = () => { const filteredNotes = useMemo(() => { let _notes = notes || [] - // NSFW Filter - _notes = _notes.filter((n) => { - if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { - return n.getMatchingTags('L').some((l) => l[1] === 'content-warning') - } - - if (filterOptions.nsfw === NSFWFilter.Hide_NSFW) { - return !n.getMatchingTags('L').some((l) => l[1] === 'content-warning') - } - - return n - }) - // Filter source // TODO: Enable source/client filter // _notes = _notes.filter( @@ -90,7 +72,7 @@ export const FeedTabPosts = () => { showing > 0 && _notes.splice(showing) return _notes - }, [filterOptions.nsfw, notes, showing]) + }, [notes, showing]) if (!userPubkey) return null diff --git a/src/styles/comments.css b/src/styles/comments.css index 9a47092..235fc8f 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); diff --git a/src/types/modsFilter.ts b/src/types/modsFilter.ts index fa5c399..6cf8dc3 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 -- 2.34.1 From 79660f63ece95897f3545a73a77fc4938028e17f Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 14:06:04 +0100 Subject: [PATCH 06/22] feat(notes): add repost filter --- src/components/Filters/FeedFilter.tsx | 23 ++++++++++++++++++++++- src/pages/feed/FeedTabPosts.tsx | 25 +++++++++++++++++++++---- src/types/modsFilter.ts | 2 +- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/components/Filters/FeedFilter.tsx b/src/components/Filters/FeedFilter.tsx index e3718a4..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' @@ -86,6 +86,27 @@ export const FeedFilter = React.memo( )} + {/* Repost filter */} + {tab === 2 && ( + + {Object.values(RepostFilter).map((item, index) => + item === RepostFilter.Only_Repost ? null : ( + + ) + )} + + )} + {children} ) diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 4fd7e40..74008a8 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -1,6 +1,6 @@ import { useLoaderData } from 'react-router-dom' import { FeedPageLoaderResult } from './loader' -import { useAppSelector, useNDKContext } from 'hooks' +import { useAppSelector, useLocalStorage, useNDKContext } from 'hooks' import { useEffect, useMemo, useState } from 'react' import { LoadingSpinner } from 'components/LoadingSpinner' import { @@ -11,6 +11,8 @@ import { } 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' export const FeedTabPosts = () => { const SHOWING_STEP = 20 @@ -24,6 +26,12 @@ export const FeedTabPosts = () => { 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 @@ -65,14 +73,23 @@ export const FeedTabPosts = () => { // ) // ) - // 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 - }, [notes, showing]) + }, [filterOptions.repost, notes, showing]) if (!userPubkey) return null diff --git a/src/types/modsFilter.ts b/src/types/modsFilter.ts index 6cf8dc3..22e0982 100644 --- a/src/types/modsFilter.ts +++ b/src/types/modsFilter.ts @@ -41,4 +41,4 @@ export interface FilterOptions { repost: RepostFilter } -export type FeedPostsFilter = Pick +export type FeedPostsFilter = Pick -- 2.34.1 From b39f8a4a4c3a4222beee30126f5be0d60ea9bda9 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 18:42:08 +0100 Subject: [PATCH 07/22] feat(profile): add posts tab --- src/components/Notes/Note.tsx | 26 +++- src/components/Notes/NoteRepostPopup.tsx | 12 +- src/components/comment/CommentContent.tsx | 4 +- src/pages/profile/index.tsx | 143 +++++++++++++++++++++- 4 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/components/Notes/Note.tsx b/src/components/Notes/Note.tsx index 042d3f5..4995736 100644 --- a/src/components/Notes/Note.tsx +++ b/src/components/Notes/Note.tsx @@ -21,7 +21,7 @@ import { useState } from 'react' import { Link } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { FeedPostsFilter, NSFWFilter, UserProfile } from 'types' -import { DEFAULT_FILTER_OPTIONS, hexToNpub } from 'utils' +import { DEFAULT_FILTER_OPTIONS, hexToNpub, log, LogType } from 'utils' import { NoteRepostPopup } from './NoteRepostPopup' import { NoteQuoteRepostPopup } from './NoteQuoteRepostPopup' import { NsfwCommentWrapper } from 'components/NsfwCommentWrapper' @@ -62,10 +62,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 = { diff --git a/src/components/Notes/NoteRepostPopup.tsx b/src/components/Notes/NoteRepostPopup.tsx index 82c743f..8091046 100644 --- a/src/components/Notes/NoteRepostPopup.tsx +++ b/src/components/Notes/NoteRepostPopup.tsx @@ -7,7 +7,7 @@ import { CommentContent } from 'components/comment/CommentContent' import { getProfilePageRoute } from 'routes' import { nip19 } from 'nostr-tools' import { UserProfile } from 'types' -import { hexToNpub } from 'utils' +import { hexToNpub, log, LogType } from 'utils' import { formatDate } from 'date-fns' interface NoteRepostProps { @@ -28,8 +28,16 @@ export const NoteRepostPopup = ({ useDidMount(async () => { const repost = await ndkEvent.repost(false) - setContent(JSON.parse(repost.content).content) 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( diff --git a/src/components/comment/CommentContent.tsx b/src/components/comment/CommentContent.tsx index 49e7f1b..e1d9b88 100644 --- a/src/components/comment/CommentContent.tsx +++ b/src/components/comment/CommentContent.tsx @@ -22,9 +22,9 @@ export const CommentContent = ({

Hide full post

)} -

+

-

+
{isTextOverflowing && !isExpanded && (

View full post

diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 478257c..50f9acf 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 = filteredNotes[filteredNotes.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 && ( +
+ +
+ )} + + ) +} -- 2.34.1 From 7d91a5753a2461c83e2c7dbdea47222fcc757a94 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 18:53:22 +0100 Subject: [PATCH 08/22] feat(comments): add title to popup --- src/components/comment/CommentsPopup.tsx | 8 ++++++-- src/routes/index.tsx | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/comment/CommentsPopup.tsx b/src/components/comment/CommentsPopup.tsx index d19e127..8c16919 100644 --- a/src/components/comment/CommentsPopup.tsx +++ b/src/components/comment/CommentsPopup.tsx @@ -25,7 +25,11 @@ import { useComments } from 'hooks/useComments' import { CommentContent } from './CommentContent' import { Dots } from 'components/Spinner' -export const CommentsPopup = () => { +interface CommentsPopupProps { + title: string +} + +export const CommentsPopup = ({ title }: CommentsPopupProps) => { const { naddr } = useParams() const location = useLocation() const { ndk } = useNDKContext() @@ -119,7 +123,7 @@ export const CommentsPopup = () => {
-

Comment replies

+

{title}

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) } ] -- 2.34.1 From 32f14dfaef7910c03d307793b16e9cc2a1aa5591 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 19:19:42 +0100 Subject: [PATCH 09/22] feat(notes): add draft to note submit textarea --- src/components/Notes/NoteSubmit.tsx | 21 +++++++++++++++------ src/pages/feed/action.ts | 9 ++++++++- src/utils/comments.ts | 2 ++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx index 6600811..c57943c 100644 --- a/src/components/Notes/NoteSubmit.tsx +++ b/src/components/Notes/NoteSubmit.tsx @@ -1,12 +1,13 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { FALLBACK_PROFILE_IMAGE } from '../../constants' -import { useAppSelector } from 'hooks' +import { useAppSelector, useLocalCache } from 'hooks' import { useProfile } from 'hooks/useProfile' import { Navigate, useNavigation, useSubmit } from 'react-router-dom' import { appRoutes } from 'routes' import { useEffect, useRef, useState } from 'react' -import { adjustTextareaHeight } from 'utils' +import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils' import { NotePreview } from './NotePreview' +import { NoteSubmitForm } from 'types' interface NoteSubmitProps { initialContent?: string | undefined @@ -22,19 +23,27 @@ export const NoteSubmit = ({ const profile = useProfile(userState.user?.pubkey as string | undefined, { cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }) - const [content, setContent] = useState(initialContent ?? '') - const [nsfw, setNsfw] = useState(false) + const [cache, setCache] = useLocalCache(NOTE_DRAFT_CACHE_KEY) + const [content, setContent] = useState(initialContent ?? cache?.content ?? '') + const [nsfw, setNsfw] = useState(cache?.nsfw ?? false) const [showPreview, setShowPreview] = useState(!!initialContent) const image = profile?.image || FALLBACK_PROFILE_IMAGE const ref = useRef(null) const submit = useSubmit() useEffect(() => { - if (ref.current && !!initialContent) { + if (ref.current && (!!initialContent || !!cache?.content)) { adjustTextareaHeight(ref.current) ref.current.focus() } - }, [initialContent]) + }, [cache?.content, initialContent]) + + useEffect(() => { + setCache({ + content, + nsfw + }) + }, [content, nsfw, setCache]) const handleContentChange = ( event: React.ChangeEvent diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts index d87f6fb..5a0c439 100644 --- a/src/pages/feed/action.ts +++ b/src/pages/feed/action.ts @@ -5,7 +5,13 @@ 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 { + log, + LogType, + NOTE_DRAFT_CACHE_KEY, + now, + removeLocalStorageItem +} from 'utils' export const feedPostRouteAction = (ndkContext: NDKContextType) => @@ -62,6 +68,7 @@ export const feedPostRouteAction = return null } else { toast.success('Note published successfully') + removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY) return redirect(getFeedNotePageRoute(note1)) } } catch (error) { 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' -- 2.34.1 From f73a4277b3e45e45e8c024eeeb0394d77e3ba8a9 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 19:24:23 +0100 Subject: [PATCH 10/22] fix(notes): rendered links open in new tab --- src/components/Notes/NoteRender.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx index 7ad2e23..5419f4d 100644 --- a/src/components/Notes/NoteRender.tsx +++ b/src/components/Notes/NoteRender.tsx @@ -29,7 +29,7 @@ export const NoteRender = ({ content }: NoteRenderProps) => { if (link.test(part)) { const [href] = part.match(link) || [] return ( - + {href} ) -- 2.34.1 From 9c153e4a18189206dd47ac96de04e5c049d89662 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 21:22:56 +0100 Subject: [PATCH 11/22] feat(notes): finalize repost action --- src/components/Notes/Note.tsx | 16 ++++- src/components/Notes/NoteSubmit.tsx | 7 +- src/pages/feed/action.ts | 108 +++++++++++++++++++--------- src/types/note.ts | 13 ++++ 4 files changed, 108 insertions(+), 36 deletions(-) diff --git a/src/components/Notes/Note.tsx b/src/components/Notes/Note.tsx index 4995736..8f37947 100644 --- a/src/components/Notes/Note.tsx +++ b/src/components/Notes/Note.tsx @@ -18,7 +18,7 @@ import { import { useComments } from 'hooks/useComments' import { nip19 } from 'nostr-tools' import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useSubmit } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { FeedPostsFilter, NSFWFilter, UserProfile } from 'types' import { DEFAULT_FILTER_OPTIONS, hexToNpub, log, LogType } from 'utils' @@ -32,6 +32,7 @@ interface NoteProps { export const Note = ({ ndkEvent }: NoteProps) => { const { ndk } = useNDKContext() + const submit = useSubmit() const userState = useAppSelector((state) => state.user) const userPubkey = userState.user?.pubkey as string | undefined const [eventProfile, setEventProfile] = useState() @@ -172,7 +173,18 @@ export const Note = ({ ndkEvent }: NoteProps) => { 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? diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx index c57943c..dd0a028 100644 --- a/src/components/Notes/NoteSubmit.tsx +++ b/src/components/Notes/NoteSubmit.tsx @@ -55,8 +55,11 @@ export const NoteSubmit = ({ const handleFormSubmit = async (event: React.FormEvent) => { event.preventDefault() const formSubmit = { - content, - nsfw + intent: 'submit', + data: { + content, + nsfw + } } // Reset form diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts index 5a0c439..61a3ce8 100644 --- a/src/pages/feed/action.ts +++ b/src/pages/feed/action.ts @@ -1,10 +1,10 @@ -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 { NoteAction, NoteSubmitForm, NoteSubmitFormErrors } from 'types' import { log, LogType, @@ -38,38 +38,25 @@ 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 - }) - try { - if (formSubmit.nsfw) ndkEvent.tags.push(['L', 'content-warning']) + const 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') - removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY) - 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) { log(true, LogType.Error, 'Failed to publish note', error) @@ -87,3 +74,60 @@ 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 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 + }) + + try { + if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning']) + + 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') + removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY) + return redirect(getFeedNotePageRoute(note1)) + } + } catch (error) { + log(true, LogType.Error, 'Failed to publish note', error) + toast.error('Failed to publish note') + return null + } +} +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/types/note.ts b/src/types/note.ts index 5e2f179..31668b3 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -1,6 +1,19 @@ +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 + } -- 2.34.1 From b8c43421f63e05f1416c4b0e280a5e4abc3ad0b2 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 19 Feb 2025 21:25:21 +0100 Subject: [PATCH 12/22] fix(quote): remove extra : --- src/components/Notes/NoteQuoteRepostPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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?
-- 2.34.1 From 7db108b338d949db1fe7e3da3a11d2e680784ca9 Mon Sep 17 00:00:00 2001 From: en Date: Thu, 20 Feb 2025 18:44:07 +0100 Subject: [PATCH 13/22] fix(comments): only count and display direct replies --- src/hooks/useComments.ts | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) 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] }) }) -- 2.34.1 From 5e521f67bc68df9c585dd077f5c687da690dd4f8 Mon Sep 17 00:00:00 2001 From: en Date: Thu, 20 Feb 2025 20:50:21 +0100 Subject: [PATCH 14/22] fix(popup): enable kind 1 quote and reposts in popup bump ndk version --- package-lock.json | 18 +-- package.json | 4 +- src/components/Notes/Note.tsx | 5 +- src/components/Notes/NoteSubmit.tsx | 5 +- src/components/comment/CommentsPopup.tsx | 187 ++++++++++++++++++++--- 5 files changed, 184 insertions(+), 35 deletions(-) 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/Notes/Note.tsx b/src/components/Notes/Note.tsx index 8f37947..9e0652c 100644 --- a/src/components/Notes/Note.tsx +++ b/src/components/Notes/Note.tsx @@ -18,7 +18,7 @@ import { import { useComments } from 'hooks/useComments' import { nip19 } from 'nostr-tools' import { useState } from 'react' -import { Link, useSubmit } from 'react-router-dom' +import { Link, useNavigation, useSubmit } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { FeedPostsFilter, NSFWFilter, UserProfile } from 'types' import { DEFAULT_FILTER_OPTIONS, hexToNpub, log, LogType } from 'utils' @@ -33,6 +33,7 @@ interface NoteProps { export const Note = ({ ndkEvent }: NoteProps) => { 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() @@ -167,6 +168,8 @@ export const Note = ({ ndkEvent }: NoteProps) => { ) : null const handleRepost = async (confirm: boolean) => { + if (navigation.state !== 'idle') return + setShowRepostPopup(false) // Cancel if not confirmed diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx index dd0a028..ef086bf 100644 --- a/src/components/Notes/NoteSubmit.tsx +++ b/src/components/Notes/NoteSubmit.tsx @@ -68,7 +68,8 @@ export const NoteSubmit = ({ submit(JSON.stringify(formSubmit), { method: 'post', - encType: 'application/json' + encType: 'application/json', + action: appRoutes.feed }) typeof handleClose === 'function' && handleClose() @@ -152,7 +153,7 @@ export const NoteSubmit = ({ style={{ padding: '5px 20px', borderRadius: '8px' }} disabled={navigation.state !== 'idle'} > - {navigation.state === 'idle' ? 'Post' : 'Posting...'} + {navigation.state === 'submitting' ? 'Posting...' : 'Post'}
diff --git a/src/components/comment/CommentsPopup.tsx b/src/components/comment/CommentsPopup.tsx index 8c16919..56698f0 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,6 +32,14 @@ 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' interface CommentsPopupProps { title: string @@ -116,6 +132,80 @@ export const CommentsPopup = ({ title }: CommentsPopupProps) => { 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 (
@@ -231,25 +321,80 @@ export const CommentsPopup = ({ title }: CommentsPopupProps) => {
- {/*
- - - -

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 !== '' && } -- 2.34.1 From 6dc1556b4c89a6e845fb02ea90bb5d1dba9f12ea Mon Sep 17 00:00:00 2001 From: en Date: Thu, 20 Feb 2025 21:06:41 +0100 Subject: [PATCH 15/22] fix(popup): enable kind 1 quote and reposts in popup comments --- src/components/comment/Comment.tsx | 182 +++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 22 deletions(-) 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 !== '' && ( )} -- 2.34.1 From fbd9b7e527b2d9692e49fe26427bdcebbe12ec46 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 10:51:38 +0100 Subject: [PATCH 16/22] fix(popup): depth calculation for kind 1 --- src/components/comment/CommentsPopup.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/comment/CommentsPopup.tsx b/src/components/comment/CommentsPopup.tsx index 56698f0..b7c4d80 100644 --- a/src/components/comment/CommentsPopup.tsx +++ b/src/components/comment/CommentsPopup.tsx @@ -40,6 +40,7 @@ import { } from '@nostr-dev-kit/ndk' import { NoteQuoteRepostPopup } from 'components/Notes/NoteQuoteRepostPopup' import { NoteRepostPopup } from 'components/Notes/NoteRepostPopup' +import _ from 'lodash' interface CommentsPopupProps { title: string @@ -63,12 +64,14 @@ export const CommentsPopup = ({ title }: CommentsPopupProps) => { ? `${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( -- 2.34.1 From be9488a752b7c5ae7827618ff94ec0e376a2f763 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 12:17:37 +0100 Subject: [PATCH 17/22] feat(notes): sub for new notes and discovery button --- src/pages/feed/FeedTabPosts.tsx | 80 ++++++++++++++++++++++++++++++--- src/pages/profile/index.tsx | 2 +- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 74008a8..26c5d80 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -7,12 +7,14 @@ 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 @@ -22,6 +24,7 @@ export const FeedTabPosts = () => { 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) @@ -38,6 +41,8 @@ export const FeedTabPosts = () => { setIsFetching(true) setIsLoadMoreVisible(true) + let sub: NDKSubscription + const filter: NDKFilter = { authors: [...followList, userPubkey], kinds: [NDKKind.Text, NDKKind.Repost], @@ -50,12 +55,45 @@ 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(() => { @@ -96,15 +134,14 @@ export const FeedTabPosts = () => { 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, { @@ -133,9 +170,42 @@ export const FeedTabPosts = () => { }) } + const discoveredCount = discoveredNotes.length + const handleDiscoveredClick = () => { + // Combine newly discovred with the notes + // Skip events already in notes + setNotes((prev) => { + return Array.from(new Set([...discoveredNotes, ...prev])) + }) + // Increase showing by the discovered count + setShowing((prev) => prev + discoveredNotes.length) + setDiscoveredNotes([]) + } + return ( <> +
+ +
{isFetching && } {filteredNotes.length === 0 && !isFetching && (
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 50f9acf..b1ff7e7 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -942,7 +942,7 @@ const ProfileTabPosts = () => { 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: [profilePubkey], kinds: [NDKKind.Text, NDKKind.Repost], -- 2.34.1 From 53dd3cc193ca86e6a853de6b6c7b4f8e1dc6c074 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 13:13:33 +0100 Subject: [PATCH 18/22] feat(notes): add try again popup for submit and validation --- src/components/Notes/NoteSubmit.tsx | 112 +++++++++++++++++++++------- src/pages/feed/FeedTabPosts.tsx | 11 ++- src/pages/feed/action.ts | 56 +++++++++----- src/types/note.ts | 10 +++ 4 files changed, 137 insertions(+), 52 deletions(-) diff --git a/src/components/Notes/NoteSubmit.tsx b/src/components/Notes/NoteSubmit.tsx index ef086bf..a77430e 100644 --- a/src/components/Notes/NoteSubmit.tsx +++ b/src/components/Notes/NoteSubmit.tsx @@ -2,12 +2,19 @@ import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk' import { FALLBACK_PROFILE_IMAGE } from '../../constants' import { useAppSelector, useLocalCache } from 'hooks' 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 { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { adjustTextareaHeight, NOTE_DRAFT_CACHE_KEY } from 'utils' import { NotePreview } from './NotePreview' -import { NoteSubmitForm } from 'types' +import { NoteSubmitActionResult, NoteSubmitForm } from 'types' +import { InputError } from 'components/Inputs/Error' +import { AlertPopup } from 'components/AlertPopup' interface NoteSubmitProps { initialContent?: string | undefined @@ -27,10 +34,18 @@ export const NoteSubmit = ({ const [content, setContent] = useState(initialContent ?? cache?.content ?? '') const [nsfw, setNsfw] = useState(cache?.nsfw ?? false) 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(null) const submit = useSubmit() - + const actionData = useActionData() as NoteSubmitActionResult + const formErrors = useMemo( + () => + actionData?.type === 'validation' ? actionData.formErrors : undefined, + [actionData] + ) useEffect(() => { if (ref.current && (!!initialContent || !!cache?.content)) { adjustTextareaHeight(ref.current) @@ -45,6 +60,57 @@ export const NoteSubmit = ({ }) }, [content, nsfw, setCache]) + const [showTryAgainPopup, setShowTryAgainPopup] = useState(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) => { + 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 = ( event: React.ChangeEvent ) => { @@ -52,29 +118,6 @@ export const NoteSubmit = ({ adjustTextareaHeight(event.currentTarget) } - const handleFormSubmit = async (event: React.FormEvent) => { - 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() - } - const handlePreviewToggle = () => { setShowPreview((prev) => !prev) } @@ -151,13 +194,24 @@ export const NoteSubmit = ({ className='btn btnMain' type='submit' style={{ padding: '5px 20px', borderRadius: '8px' }} - disabled={navigation.state !== 'idle'} + disabled={navigation.state !== 'idle' || !content.length} > {navigation.state === 'submitting' ? 'Posting...' : 'Post'}
+ {typeof formErrors?.content !== 'undefined' && ( + + )} {showPreview && } + {showTryAgainPopup && ( + setShowTryAgainPopup(false)} + header={'Post'} + label={`Posting timed out. Do you want to try again?`} + /> + )}
diff --git a/src/pages/feed/FeedTabPosts.tsx b/src/pages/feed/FeedTabPosts.tsx index 26c5d80..19ef011 100644 --- a/src/pages/feed/FeedTabPosts.tsx +++ b/src/pages/feed/FeedTabPosts.tsx @@ -129,6 +129,11 @@ export const FeedTabPosts = () => { return _notes }, [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 = () => { @@ -170,15 +175,15 @@ export const FeedTabPosts = () => { }) } - const discoveredCount = discoveredNotes.length + const discoveredCount = newNotes.length const handleDiscoveredClick = () => { // Combine newly discovred with the notes // Skip events already in notes setNotes((prev) => { - return Array.from(new Set([...discoveredNotes, ...prev])) + return [...newNotes, ...prev] }) // Increase showing by the discovered count - setShowing((prev) => prev + discoveredNotes.length) + setShowing((prev) => prev + discoveredCount) setDiscoveredNotes([]) } diff --git a/src/pages/feed/action.ts b/src/pages/feed/action.ts index 61a3ce8..7fc4fc9 100644 --- a/src/pages/feed/action.ts +++ b/src/pages/feed/action.ts @@ -4,13 +4,19 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom' import { toast } from 'react-toastify' import { getFeedNotePageRoute } from 'routes' import { store } from 'store' -import { NoteAction, NoteSubmitForm, NoteSubmitFormErrors } from 'types' +import { + NoteAction, + NoteSubmitForm, + NoteSubmitFormErrors, + TimeoutError +} from 'types' import { log, LogType, NOTE_DRAFT_CACHE_KEY, now, - removeLocalStorageItem + removeLocalStorageItem, + timeout } from 'utils' export const feedPostRouteAction = @@ -38,8 +44,9 @@ export const feedPostRouteAction = return null } + let action: NoteAction | undefined try { - const action = (await request.json()) as NoteAction + action = (await request.json()) as NoteAction switch (action.intent) { case 'submit': return await handleActionSubmit( @@ -59,6 +66,14 @@ export const feedPostRouteAction = 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 @@ -82,7 +97,11 @@ async function handleActionSubmit( ) { const formErrors = validateFormData(data) - if (Object.keys(formErrors).length) return formErrors + if (Object.keys(formErrors).length) + return { + type: 'validation', + formErrors + } const content = decodeURIComponent(data.content!) const currentTimeStamp = now() @@ -98,24 +117,21 @@ async function handleActionSubmit( pubkey }) - try { - if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning']) + if (data.nsfw) ndkEvent.tags.push(['L', 'content-warning']) - 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') - removeLocalStorageItem(NOTE_DRAFT_CACHE_KEY) - return redirect(getFeedNotePageRoute(note1)) - } - } catch (error) { - log(true, LogType.Error, 'Failed to publish note', error) - toast.error('Failed to publish note') + 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) { diff --git a/src/types/note.ts b/src/types/note.ts index 31668b3..a6c6204 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -17,3 +17,13 @@ export type NoteAction = note1: string data: NostrEvent } + +export type NoteSubmitActionResult = + | { + type: 'timeout' + action: NoteAction + } + | { + type: 'validation' + formErrors: NoteSubmitFormErrors + } -- 2.34.1 From e321861fc4d6537f43a81ba0d938ffe82a44fafb Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 14:20:18 +0100 Subject: [PATCH 19/22] feat(notes): render nip05 as profile in preview --- src/components/Notes/NoteRender.tsx | 10 +++- src/components/ProfileSection.tsx | 76 +++++++++++++++++++---------- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx index 5419f4d..fbe492a 100644 --- a/src/components/Notes/NoteRender.tsx +++ b/src/components/Notes/NoteRender.tsx @@ -6,6 +6,7 @@ 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' interface NoteRenderProps { content: string @@ -16,13 +17,17 @@ 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) => { @@ -63,6 +68,9 @@ export const NoteRender = ({ content }: NoteRenderProps) => { } catch (error) { return part } + } else if (NIP05_REGEX.test(part)) { + const [nip05] = part.match(NIP05_REGEX) || [] + return } else { return part } diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index d00d289..36d6b64 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,52 @@ 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() + console.log(`[debug]`, pubkey, nip05) + const [hexPubkey, setHexPubkey] = useState() + const profile = useProfile(hexPubkey, { cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }) + useEffect(() => { + if (pubkey) { + setHexPubkey(pubkey) + } else if (nip05) { + NDKUser.fromNip05(nip05, ndk).then((user) => { + if (user?.pubkey) { + setHexPubkey(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} } -- 2.34.1 From 01ca102dcc94f8a58488e3808cd0aea14d0076e9 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 14:39:53 +0100 Subject: [PATCH 20/22] fix(profile): add npub to hex conversion in link --- src/components/ProfileSection.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 36d6b64..0899910 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -587,18 +587,17 @@ type ProfileLinkProps = { } export const ProfileLink = ({ pubkey, nip05 }: ProfileLinkProps) => { const { ndk } = useNDKContext() - console.log(`[debug]`, pubkey, nip05) const [hexPubkey, setHexPubkey] = useState() const profile = useProfile(hexPubkey, { cacheUsage: NDKSubscriptionCacheUsage.PARALLEL }) useEffect(() => { if (pubkey) { - setHexPubkey(pubkey) + setHexPubkey(npubToHex(pubkey)!) } else if (nip05) { NDKUser.fromNip05(nip05, ndk).then((user) => { if (user?.pubkey) { - setHexPubkey(user.pubkey) + setHexPubkey(npubToHex(user.pubkey)!) } }) } -- 2.34.1 From 5f33c5e68a7762414cb6cfd960a853bc23bc2eb6 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 14:49:24 +0100 Subject: [PATCH 21/22] fix(notes): render image preview --- src/components/Notes/NoteRender.tsx | 10 ++++++++++ src/styles/comments.css | 3 --- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx index fbe492a..da3d4d6 100644 --- a/src/components/Notes/NoteRender.tsx +++ b/src/components/Notes/NoteRender.tsx @@ -7,6 +7,7 @@ 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 } from 'utils' interface NoteRenderProps { content: string @@ -33,6 +34,15 @@ export const NoteRender = ({ content }: NoteRenderProps) => { const _parts = parts.map((part, index) => { if (link.test(part)) { const [href] = part.match(link) || [] + + if (href && isValidUrl(href)) { + // Image + if (isValidImageUrl(href)) { + return + } + } + + // Link return ( {href} diff --git a/src/styles/comments.css b/src/styles/comments.css index 235fc8f..3fa7e13 100644 --- a/src/styles/comments.css +++ b/src/styles/comments.css @@ -65,9 +65,6 @@ .IBMSMSMBSSCL_CBText { white-space: pre-line; - display: flex; - flex-direction: column; - grid-gap: 5px; } .IBMSMSMBSSCL_CBTextStatus { -- 2.34.1 From 83adfe39644c6a1fba72ae733547db3bab99f0a2 Mon Sep 17 00:00:00 2001 From: en Date: Fri, 21 Feb 2025 14:57:10 +0100 Subject: [PATCH 22/22] fix(notes): render video preview --- src/components/Notes/NoteRender.tsx | 7 +++++-- src/utils/url.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Notes/NoteRender.tsx b/src/components/Notes/NoteRender.tsx index da3d4d6..907eeba 100644 --- a/src/components/Notes/NoteRender.tsx +++ b/src/components/Notes/NoteRender.tsx @@ -7,7 +7,7 @@ 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 } from 'utils' +import { isValidImageUrl, isValidUrl, isValidVideoUrl } from 'utils' interface NoteRenderProps { content: string @@ -36,9 +36,12 @@ export const NoteRender = ({ content }: NoteRenderProps) => { const [href] = part.match(link) || [] if (href && isValidUrl(href)) { - // Image if (isValidImageUrl(href)) { + // Image return + } else if (isValidVideoUrl(href)) { + // Video + return