diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index 25ac9ab..3f4b38b 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -28,6 +28,8 @@ jobs: echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Create Build diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 01bc5ff..41c5d3e 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -28,6 +28,8 @@ jobs: echo "VITE_SITE_WOT_NPUB"=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Create Build diff --git a/.github/workflows/release-pages-production.yaml b/.github/workflows/release-pages-production.yaml index 10d4fde..84fed7e 100644 --- a/.github/workflows/release-pages-production.yaml +++ b/.github/workflows/release-pages-production.yaml @@ -36,6 +36,8 @@ jobs: echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env + echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env + echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env cat .env - name: Build run: npm run build diff --git a/src/assets/img/DEGM Thumb.png b/src/assets/img/DEGM Thumb.png index 763a04b..6dd67f7 100644 Binary files a/src/assets/img/DEGM Thumb.png and b/src/assets/img/DEGM Thumb.png differ diff --git a/src/components/Internal/Reactions.tsx b/src/components/Internal/Reactions.tsx index 4e69f13..d990429 100644 --- a/src/components/Internal/Reactions.tsx +++ b/src/components/Internal/Reactions.tsx @@ -1,3 +1,4 @@ +import { Dots } from 'components/Spinner' import { useReactions } from 'hooks' import { Addressable } from 'types' @@ -19,15 +20,13 @@ export const Reactions = ({ addressable }: ReactionsProps) => { aTag: addressable.aTag }) - if (!isDataLoaded) return null - return ( <>
handleReaction(true)} + onClick={isDataLoaded ? () => handleReaction(true) : undefined} >
{
-

{likesCount}

+

+ {isDataLoaded ? likesCount : } +

@@ -50,7 +51,7 @@ export const Reactions = ({ addressable }: ReactionsProps) => { className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${ hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : '' }`} - onClick={() => handleReaction()} + onClick={isDataLoaded ? () => handleReaction() : undefined} >
{
-

{disLikesCount}

+

+ {isDataLoaded ? disLikesCount : } +

diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 0000000..786c6ba --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,9 @@ +import styles from '../styles/dotsSpinner.module.scss' + +export const Spinner = () => ( +
+
+
+) + +export const Dots = () => diff --git a/src/components/comment/index.tsx b/src/components/comment/index.tsx index 8d7fd93..b9a87b3 100644 --- a/src/components/comment/index.tsx +++ b/src/components/comment/index.tsx @@ -1,4 +1,5 @@ import { NDKEvent } from '@nostr-dev-kit/ndk' +import { Dots, Spinner } from 'components/Spinner' import { ZapPopUp } from 'components/Zap' import { formatDate } from 'date-fns' import { @@ -59,6 +60,15 @@ export const Comments = ({ addressable, setCommentCount }: Props) => { author: AuthorFilterEnum.All_Comments }) + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + // Initial loading to indicate comments fetching (stop after 5 seconds) + const t = window.setTimeout(() => setIsLoading(false), 5000) + return () => { + window.clearTimeout(t) + } + }, []) + useEffect(() => { setCommentCount(commentEvents.length) }, [commentEvents, setCommentCount]) @@ -175,8 +185,18 @@ export const Comments = ({ addressable, setCommentCount }: Props) => { return true } + const handleDiscoveredClick = () => { + setVisible(commentEvents) + } + const [visible, setVisible] = useState([]) + useEffect(() => { + if (isLoading) { + setVisible(commentEvents) + } + }, [commentEvents, isLoading]) + const comments = useMemo(() => { - let filteredComments = commentEvents + let filteredComments = visible if (filterOptions.author === AuthorFilterEnum.Creator_Comments) { filteredComments = filteredComments.filter( (comment) => comment.pubkey === addressable.author @@ -190,13 +210,28 @@ export const Comments = ({ addressable, setCommentCount }: Props) => { } return filteredComments - }, [commentEvents, filterOptions, addressable.author]) + }, [visible, filterOptions.author, filterOptions.sort, addressable.author]) + const discoveredCount = commentEvents.length - visible.length return (

Comments

- + {/* Hide comment form if aTag is missing */} + {!!addressable.aTag && } +
+ {isLoading ? ( + + ) : ( + + )} +
{
- + {profile?.displayName || profile?.name || ''}{' '} - - + + {hexToNpub(props.pubkey)} - +
@@ -446,15 +481,13 @@ const Reactions = (props: Event) => { eTag: props.id }) - if (!isDataLoaded) return null - return ( <>
handleReaction(true)} + onClick={isDataLoaded ? () => handleReaction(true) : undefined} > { > -

{likesCount}

+

+ {isDataLoaded ? likesCount : } +

@@ -475,7 +510,7 @@ const Reactions = (props: Event) => { className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${ hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : '' }`} - onClick={() => handleReaction()} + onClick={isDataLoaded ? () => handleReaction() : undefined} > { > -

{disLikesCount}

+

+ {isDataLoaded ? disLikesCount : } +

diff --git a/src/constants.ts b/src/constants.ts index d4a56d8..a654aab 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -21,7 +21,12 @@ export const LANDING_PAGE_DATA = { 'ELDEN RING', 'The Coffin of Andy and Leyley' ], - featuredBlogPosts: [] + featuredBlogPosts: [ + 'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c', + 'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y', + 'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz', + 'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2573jhg9trsu6vgav9gnn4dffkzk2ww3yrjejnc2s' + ] } // we use this object to check if a user has reacted positively or negatively to a post // reactions are kind 7 events and their content is either emoji icon or emoji shortcode diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index f3a33dc..e7cd380 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -21,7 +21,8 @@ import { log, LogType, npubToHex, - orderEventsChronologically + orderEventsChronologically, + timeout } from 'utils' type FetchModsOptions = { @@ -241,8 +242,11 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { hexKey: string, userRelaysType: UserRelaysType ): Promise => { - // Find the user's relays. - const relayUrls = await getRelayListForUser(hexKey, ndk) + // Find the user's relays (10s timeout). + const relayUrls = await Promise.race([ + getRelayListForUser(hexKey, ndk), + timeout(10000) + ]) .then((ndkRelayList) => { if (ndkRelayList) return ndkRelayList[userRelaysType] return [] // Return an empty array if ndkRelayList is undefined diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index 574c3eb..0f029bb 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -5,7 +5,7 @@ import { Event, kinds, UnsignedEvent } from 'nostr-tools' import { useMemo, useState } from 'react' import { toast } from 'react-toastify' import { UserRelaysType } from 'types' -import { abbreviateNumber, log, LogType, now } from 'utils' +import { abbreviateNumber, log, LogType, now, timeout } from 'utils' type UseReactionsParams = { pubkey: string @@ -32,7 +32,11 @@ export const useReactions = (params: UseReactionsParams) => { filter['#e'] = [params.eTag] } - fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read) + // 1 minute timeout + Promise.race([ + fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read), + timeout(60000) + ]) .then((events) => { setReactionEvents(events) }) diff --git a/src/layout/header.tsx b/src/layout/header.tsx index 1bc9943..e21ba45 100644 --- a/src/layout/header.tsx +++ b/src/layout/header.tsx @@ -381,16 +381,14 @@ const RegisterButtonWithDialog = () => { Browser Extensions (Windows)

- Once you create your "account" on any of these ( - - Here's a quick video guide - - ), come back and click login, then sign-in with - extension. -

+ Once you create your "account" on any of these, come back and click login, then sign-in with + extension. Here's a quick video guide, and here's a guide post to help with this process.

+
+ +
{ +interface NotFoundPageProps { + title: string + message: string +} + +export const NotFoundPage = ({ + title = 'Page not found', + message = "The page you're attempting to visit doesn't exist" +}: Partial) => { + const error = useRouteError() as Partial + return (
-

Page not found

+

{error?.title || title}

-

The page you're attempting to visit doesn't exist

+

{error?.message || message}

{
- {!blog ? ( - - ) : ( + {blog && ( <>
@@ -301,6 +299,7 @@ export const BlogPage = () => { )}
diff --git a/src/pages/blog/loader.ts b/src/pages/blog/loader.ts index aadddb0..0b06b50 100644 --- a/src/pages/blog/loader.ts +++ b/src/pages/blog/loader.ts @@ -1,11 +1,16 @@ -import { filterForEventsTaggingId, NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKFilter } from '@nostr-dev-kit/ndk' +import { PROFILE_BLOG_FILTER_LIMIT } from '../../constants' import { NDKContextType } from 'contexts/NDKContext' import { kinds, nip19 } from 'nostr-tools' import { LoaderFunctionArgs, redirect } from 'react-router-dom' -import { toast } from 'react-toastify' import { appRoutes } from 'routes' import { store } from 'store' -import { BlogPageLoaderResult, FilterOptions, NSFWFilter } from 'types' +import { + BlogPageLoaderResult, + FilterOptions, + ModeratedFilter, + NSFWFilter +} from 'types' import { DEFAULT_FILTER_OPTIONS, getLocalStorageItem, @@ -16,62 +21,74 @@ import { extractBlogCardDetails, extractBlogDetails } from 'utils/blog' export const blogRouteLoader = (ndkContext: NDKContextType) => - async ({ params }: LoaderFunctionArgs) => { + async ({ params, request }: LoaderFunctionArgs) => { const { naddr } = params if (!naddr) { log(true, LogType.Error, 'Required naddr.') return redirect(appRoutes.blogs) } - // Decode author from naddr - const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) - const { pubkey } = decoded.data + // Decode author and identifier from naddr + let pubkey: string | undefined + let identifier: string | undefined + try { + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + pubkey = decoded.data.pubkey + identifier = decoded.data.identifier + } catch (error) { + log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error) + throw new Error('Failed to fetch the blog. The address might be wrong') + } + + const userState = store.getState().user + const loggedInUserPubkey = userState?.user?.pubkey as string | undefined + + // Check if editing and the user is the original author + // Redirect if NOT + const url = new URL(request.url) + const isEditMode = url.pathname.endsWith('/edit') + if (isEditMode && loggedInUserPubkey !== pubkey) { + return redirect(appRoutes.blogs) + } try { - // Get the filter with #a from naddr for the main blog content - const filter = filterForEventsTaggingId(naddr) - if (!filter) { - log(true, LogType.Error, 'Unable to create filter from blog naddr.') - return redirect(appRoutes.blogs) + // Set the filter for the main blog content + const filter = { + kinds: [kinds.LongFormArticle], + authors: [pubkey], + '#d': [identifier] } - // Update kinds to make sure we fetch correct event kind - filter.kinds = [kinds.LongFormArticle] - - const userState = store.getState().user // Get the blog filter options for latest blogs const filterOptions = JSON.parse( getLocalStorageItem('filter-blog', DEFAULT_FILTER_OPTIONS) ) as FilterOptions - // Fetch 4 in case the current blog is included in the latest - const latestModsFilter: NDKFilter = { + // Fetch more in case the current blog is included in the latest and filters remove some + const latestFilter: NDKFilter = { authors: [pubkey], kinds: [kinds.LongFormArticle], - limit: 4 + limit: PROFILE_BLOG_FILTER_LIMIT } // Add source filter if (filterOptions.source === window.location.host) { - latestModsFilter['#r'] = [filterOptions.source] + latestFilter['#r'] = [filterOptions.source] } // Filter by NSFW tag + // NSFWFilter.Only_NSFW -> fetch with content-warning label // NSFWFilter.Show_NSFW -> filter not needed - // NSFWFilter.Only_NSFW -> true - // NSFWFilter.Hide_NSFW -> false - if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { - latestModsFilter['#nsfw'] = [ - (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() - ] + // NSFWFilter.Hide_NSFW -> up the limit and filter after fetch + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + latestFilter['#L'] = ['content-warning'] } - // Parallel fetch blog event, latest events, mute, and nsfw lists in parallel + // Parallel fetch blog event, latest events, mute, and nsfw lists const settled = await Promise.allSettled([ ndkContext.fetchEvent(filter), - ndkContext.fetchEvents(latestModsFilter), - ndkContext.getMuteLists(userState?.user?.pubkey as string), + ndkContext.fetchEvents(latestFilter), + ndkContext.getMuteLists(loggedInUserPubkey), // Pass pubkey for logged-in users ndkContext.getNSFWList() ]) - const result: BlogPageLoaderResult = { blog: undefined, latest: [], @@ -93,6 +110,12 @@ export const blogRouteLoader = ) } + // Throw an error if we are missing the main blog result + // Handle it with the react-router's errorComponent + if (!result.blog) { + throw new Error('We are unable to find the blog on the relays') + } + // Check the lateast blog events const fetchEventsResult = settled[1] if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { @@ -100,7 +123,6 @@ export const blogRouteLoader = result.latest = fetchEventsResult.value .map(extractBlogCardDetails) .filter((b) => b.id !== result.blog?.id) // Filter out current blog if present - .slice(0, 3) // Take only three } else if (fetchEventsResult.status === 'rejected') { log( true, @@ -110,22 +132,48 @@ export const blogRouteLoader = ) } - const muteList = settled[2] - if (muteList.status === 'fulfilled' && muteList.value) { - if (muteList && muteList.value) { + const muteLists = settled[2] + if (muteLists.status === 'fulfilled' && muteLists.value) { + if (muteLists && muteLists.value) { if (result.blog && result.blog.aTag) { if ( - muteList.value.admin.replaceableEvents.includes( + muteLists.value.admin.replaceableEvents.includes( result.blog.aTag ) || - muteList.value.user.replaceableEvents.includes(result.blog.aTag) + muteLists.value.user.replaceableEvents.includes(result.blog.aTag) ) { result.isBlocked = true } } + + // Moderate the latest + const isAdmin = + userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB + const isOwner = + userState.user?.pubkey && userState.user.pubkey === pubkey + const isUnmoderatedFully = + filterOptions.moderated === ModeratedFilter.Unmoderated_Fully + + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" + // Allow "Unmoderated Fully" when author visits own profile + if (!((isAdmin || isOwner) && isUnmoderatedFully)) { + result.latest = result.latest.filter( + (b) => + !muteLists.value.admin.authors.includes(b.author!) && + !muteLists.value.admin.replaceableEvents.includes(b.aTag!) + ) + } + + if (filterOptions.moderated === ModeratedFilter.Moderated) { + result.latest = result.latest.filter( + (b) => + !muteLists.value.user.authors.includes(b.author!) && + !muteLists.value.user.replaceableEvents.includes(b.aTag!) + ) + } } - } else if (muteList.status === 'rejected') { - log(true, LogType.Error, 'Issue fetching mute list', muteList.reason) + } else if (muteLists.status === 'rejected') { + log(true, LogType.Error, 'Issue fetching mute list', muteLists.reason) } const nsfwList = settled[3] @@ -147,15 +195,14 @@ export const blogRouteLoader = } } - // Check if the the latest blogs too + // Check the latest blogs too result.latest = result.latest.map((b) => { - if (b) { - const isMissingNsfwTag = - !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) + // Add nsfw tag if it's missing + const isMissingNsfwTag = + !b.nsfw && b.aTag && nsfwList.value.includes(b.aTag) - if (isMissingNsfwTag) { - b.nsfw = true - } + if (isMissingNsfwTag) { + b.nsfw = true } return b }) @@ -163,15 +210,24 @@ export const blogRouteLoader = log(true, LogType.Error, 'Issue fetching nsfw list', nsfwList.reason) } + // Filter latest, sort and take only three + result.latest = result.latest + .filter( + // Filter out the NSFW if selected + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + .sort((a, b) => + a.published_at && b.published_at ? b.published_at - a.published_at : 0 + ) + .slice(0, 3) + return result } catch (error) { - log( - true, - LogType.Error, - 'An error occurred in fetching blog details from relays', - error - ) - toast.error('An error occurred in fetching blog details from relays') - return redirect(appRoutes.blogs) + let message = 'An error occurred in fetching blog details from relays' + log(true, LogType.Error, message, error) + if (error instanceof Error) { + message = error.message + throw new Error(message) + } } } diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 375f1a8..08d0848 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react' -import { useLoaderData, useSearchParams } from 'react-router-dom' +import { useLoaderData, useNavigation, useSearchParams } from 'react-router-dom' import { useLocalStorage } from 'hooks' import { BlogCardDetails, NSFWFilter, SortBy } from 'types' import { SearchInput } from '../../components/SearchInput' @@ -10,8 +10,10 @@ import '../../styles/search.css' import '../../styles/styles.css' import { PaginationWithPageNumbers } from 'components/Pagination' import { scrollIntoView } from 'utils' +import { LoadingSpinner } from 'components/LoadingSpinner' export const BlogsPage = () => { + const navigation = useNavigation() const blogs = useLoaderData() as Partial[] | undefined const [filterOptions, setFilterOptions] = useLocalStorage( 'filter-blog-curated', @@ -105,6 +107,7 @@ export const BlogsPage = () => { return (
+ {navigation.state !== 'idle' && }
async () => { authors: blogHexkeys, kinds: [kinds.LongFormArticle] } - const events = await ndkContext.fetchEvents(filter) - if (!events) { - log(true, LogType.Error, 'Unable to fetch the blog events.') - return null + const settled = await Promise.allSettled([ + ndkContext.fetchEvents(filter), + ndkContext.getMuteLists() + ]) + + let blogs: Partial[] = [] + const fetchEventsResult = settled[0] + if (fetchEventsResult.status === 'fulfilled' && fetchEventsResult.value) { + // Extract the blog card details from the events + blogs = fetchEventsResult.value + .map(extractBlogCardDetails) + .filter((b) => b.naddr) + } else if (fetchEventsResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Unable to fetch the blog events.', + fetchEventsResult.reason + ) + return [] } - return events.map(extractBlogCardDetails).filter((e) => e.naddr) + const muteListResult = settled[1] + if (muteListResult.status === 'fulfilled' && muteListResult.value) { + // Filter out the blocked events + blogs = blogs.filter( + (b) => + b.aTag && + !muteListResult.value.admin.replaceableEvents.includes(b.aTag) + ) + } else if (muteListResult.status === 'rejected') { + log( + true, + LogType.Error, + 'Failed to fetch mutelists.', + muteListResult.reason + ) + } + return blogs } catch (error) { log( true, @@ -30,6 +63,6 @@ export const blogsRouteLoader = (ndkContext: NDKContextType) => async () => { 'An error occurred in fetching blog details from relays', error ) - return null + return [] } } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 0dcb7ac..72168a1 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,12 +1,12 @@ import { kinds, nip19 } from 'nostr-tools' import { useMemo, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useNavigation } from 'react-router-dom' import { A11y, Autoplay, Navigation, Pagination } from 'swiper/modules' import { Swiper, SwiperSlide } from 'swiper/react' import { BlogCard } from '../components/BlogCard' import { GameCard } from '../components/GameCard' import { ModCard } from '../components/ModCard' -import { LANDING_PAGE_DATA } from '../constants' +import { LANDING_PAGE_DATA, PROFILE_BLOG_FILTER_LIMIT } from '../constants' import { useAppSelector, useDidMount, @@ -32,14 +32,12 @@ import '../styles/SimpleSlider.css' import '../styles/styles.css' // Import Swiper styles -import { - filterForEventsTaggingId, - NDKEvent, - NDKFilter -} from '@nostr-dev-kit/ndk' +import { NDKFilter } from '@nostr-dev-kit/ndk' import 'swiper/css' import 'swiper/css/navigation' import 'swiper/css/pagination' +import { LoadingSpinner } from 'components/LoadingSpinner' +import { Spinner } from 'components/Spinner' export const HomePage = () => { const navigate = useNavigate() @@ -260,6 +258,8 @@ const DisplayLatestMods = () => { useDidMount(() => { fetchMods({ source: window.location.host }) .then((mods) => { + // Sort by the latest (published_at descending) + mods.sort((a, b) => b.published_at - a.published_at) const wotFilteredMods = mods.filter( (mod) => siteWot.includes(mod.author) || userWot.includes(mod.author) ) @@ -316,14 +316,6 @@ const DisplayLatestMods = () => { ) } -const Spinner = () => { - return ( -
-
-
- ) -} - const DisplayLatestBlogs = () => { const [blogs, setBlogs] = useState[]>() const { fetchEvents } = useNDKContext() @@ -331,86 +323,92 @@ const DisplayLatestBlogs = () => { sort: SortBy.Latest, nsfw: NSFWFilter.Hide_NSFW }) + const navigation = useNavigation() useDidMount(() => { const fetchBlogs = async () => { try { // Show maximum of 4 blog posts // 2 should be featured and the most recent 2 from blog npubs // Populate the filter from known naddr (constants.ts) - const filters: NDKFilter[] = [] + const filter: NDKFilter = { + kinds: [kinds.LongFormArticle], + authors: [], + '#d': [] + } for (let i = 0; i < LANDING_PAGE_DATA.featuredBlogPosts.length; i++) { try { const naddr = LANDING_PAGE_DATA.featuredBlogPosts[i] - const filterId = filterForEventsTaggingId(naddr) - if (filterId) { - filters.push(filterId) + const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`) + const { pubkey, identifier } = decoded.data + if (!filter.authors?.includes(pubkey)) { + filter.authors?.push(pubkey) + } + if (!filter.authors?.includes(identifier)) { + filter['#d']?.push(identifier) } } catch (error) { // Silently ignore } } - // Create a single filter based on multiple #a's - const filter = filters.reduce( - (filter, id) => { - const a = id['#a'] - if (a) { - filter['#a']?.push(a[0]) - } - return filter - }, - { - '#a': [] - } as NDKFilter - ) + // Prepare filter for the latest const blogNpubs = import.meta.env.VITE_BLOG_NPUBS.split(',') const blogHexkeys = blogNpubs .map(npubToHex) .filter((hexkey) => hexkey !== null) - // We fetch 4 posts in case of duplicates (from featured) + // We fetch more posts in case of duplicates (from featured) const latestFilter: NDKFilter = { authors: blogHexkeys, kinds: [kinds.LongFormArticle], - limit: 4 + limit: PROFILE_BLOG_FILTER_LIMIT } // Filter by NSFW tag - // NSFWFilter.Show_NSFW -> filter not needed - // NSFWFilter.Only_NSFW -> true - // NSFWFilter.Hide_NSFW -> false - if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { - latestFilter['#nsfw'] = [ - (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() - ] + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + latestFilter['#L'] = ['content-warning'] } const results = await Promise.allSettled([ - fetchEvents({ ...filter, kinds: [kinds.LongFormArticle] }), + fetchEvents(filter), fetchEvents(latestFilter) ]) - const events: NDKEvent[] = [] + const events: Partial[] = [] // Get featured blogs posts result results.forEach((r) => { // Add events from both promises to the array if (r.status === 'fulfilled' && r.value) { - events.push(...r.value) + events.push( + ...r.value + .map(extractBlogCardDetails) // Extract the blog card details + .sort( + // Sort each result by published_at in descending order + // We can't sort everything at once we'd lose prefered + (a, b) => + a.published_at && b.published_at + ? b.published_at - a.published_at + : 0 + ) + ) } }) // Remove duplicates const unique = Array.from( events + .filter((b) => b.id) .reduce((map, obj) => { - map.set(obj.id, obj) + map.set(obj.id!, obj) return map - }, new Map()) + }, new Map>()) .values() + ).filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) ) - const latest = unique.slice(0, 4) - setBlogs(latest.map(extractBlogCardDetails)) + const latest = unique.slice(0, 4) + setBlogs(latest) } catch (error) { log( true, @@ -427,6 +425,9 @@ const DisplayLatestBlogs = () => { return (
+ {navigation.state !== 'idle' && ( + + )}

Blog Posts

diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 584c34f..56758a6 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -14,11 +14,11 @@ import { useNDKContext, useNSFWList } from 'hooks' -import { kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { kinds, UnsignedEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useParams, Navigate, Link, useLoaderData } from 'react-router-dom' +import { Link, useLoaderData, useNavigation } from 'react-router-dom' import { toast } from 'react-toastify' -import { appRoutes, getProfilePageRoute } from 'routes' +import { appRoutes } from 'routes' import { BlogCardDetails, FilterOptions, @@ -32,8 +32,6 @@ import { copyTextToClipboard, DEFAULT_FILTER_OPTIONS, extractBlogCardDetails, - log, - LogType, now, npubToHex, scrollIntoView, @@ -46,23 +44,11 @@ import { BlogCard } from 'components/BlogCard' export const ProfilePage = () => { const { + profilePubkey, profile, isBlocked: _isBlocked, isOwnProfile } = useLoaderData() as ProfilePageLoaderResult - // Try to decode nprofile parameter - const { nprofile } = useParams() - let profilePubkey: string | undefined - try { - const value = nprofile - ? nip19.decode(nprofile as `nprofile1${string}`) - : undefined - profilePubkey = value?.data.pubkey - } catch (error) { - // Silently ignore and redirect to home or logged in user - log(true, LogType.Error, 'Failed to decode nprofile.', error) - } - const scrollTargetRef = useRef(null) const { ndk, publish, fetchEventFromUserRelays, fetchMods } = useNDKContext() const userState = useAppSelector((state) => state.user) @@ -262,6 +248,7 @@ export const ProfilePage = () => { setIsLoading(true) switch (tab) { case 0: + setLoadingSpinnerDesc('Fetching mods..') fetchMods({ source: filterOptions.source, author: profilePubkey }) .then((res) => { setMods(res) @@ -285,22 +272,6 @@ export const ProfilePage = () => { profilePubkey ) - // Redirect route - let profileRoute = appRoutes.home - if (!nprofile && userState.auth && userState.user) { - // Redirect to user's profile is no profile is linked - const userHexKey = npubToHex(userState.user.npub as string) - - if (userHexKey) { - profileRoute = getProfilePageRoute( - nip19.nprofileEncode({ - pubkey: userHexKey - }) - ) - } - } - if (!profilePubkey) return - return (
@@ -690,6 +661,7 @@ const ReportUserPopup = ({ const ProfileTabBlogs = () => { const { profile, muteLists, nsfwList } = useLoaderData() as ProfilePageLoaderResult + const navigation = useNavigation() const { fetchEvents } = useNDKContext() const [filterOptions] = useLocalStorage('filter-blog', DEFAULT_FILTER_OPTIONS) const [isLoading, setIsLoading] = useState(true) @@ -704,10 +676,8 @@ const ProfileTabBlogs = () => { filter['#r'] = [host] } - if (filterOptions.nsfw !== NSFWFilter.Show_NSFW) { - filter['#nsfw'] = [ - (filterOptions.nsfw === NSFWFilter.Only_NSFW).toString() - ] + if (filterOptions.nsfw === NSFWFilter.Only_NSFW) { + filter['#L'] = ['content-warning'] } return filter @@ -725,7 +695,7 @@ const ProfileTabBlogs = () => { } fetchEvents(filter) .then((events) => { - setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) setHasMore(events.length > PROFILE_BLOG_FILTER_LIMIT) }) .finally(() => { @@ -752,7 +722,7 @@ const ProfileTabBlogs = () => { setHasMore(nextBlogs.length > PROFILE_BLOG_FILTER_LIMIT) setPage((prev) => prev + 1) setBlogs( - nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((e) => e.naddr) + nextBlogs.slice(0, PROFILE_BLOG_FILTER_LIMIT).filter((b) => b.naddr) ) }) .finally(() => setIsLoading(false)) @@ -775,7 +745,7 @@ const ProfileTabBlogs = () => { .then((events) => { setHasMore(true) setPage((prev) => prev - 1) - setBlogs(events.map(extractBlogCardDetails).filter((e) => e.naddr)) + setBlogs(events.map(extractBlogCardDetails).filter((b) => b.naddr)) }) .finally(() => setIsLoading(false)) } @@ -799,6 +769,11 @@ const ProfileTabBlogs = () => { }) } + // Filter nsfw (Hide_NSFW option) + _blogs = _blogs.filter( + (b) => !(b.nsfw && filterOptions.nsfw === NSFWFilter.Hide_NSFW) + ) + // Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully" // Allow "Unmoderated Fully" when author visits own profile if (!((isAdmin || isOwner) && isUnmoderatedFully)) { @@ -845,7 +820,9 @@ const ProfileTabBlogs = () => { return ( <> - {isLoading && } + {(isLoading || navigation.state !== 'idle') && ( + + )} diff --git a/src/pages/profile/loader.ts b/src/pages/profile/loader.ts index aecb15d..f3ac4c0 100644 --- a/src/pages/profile/loader.ts +++ b/src/pages/profile/loader.ts @@ -4,9 +4,10 @@ import { LoaderFunctionArgs, redirect } from 'react-router-dom' import { appRoutes, getProfilePageRoute } from 'routes' import { store } from 'store' import { MuteLists, UserProfile } from 'types' -import { log, LogType } from 'utils' +import { log, LogType, npubToHex } from 'utils' export interface ProfilePageLoaderResult { + profilePubkey: string profile: UserProfile isBlocked: boolean isOwnProfile: boolean @@ -24,10 +25,25 @@ export const profileRouteLoader = const { nprofile } = params let profilePubkey: string | undefined try { - const value = nprofile - ? nip19.decode(nprofile as `nprofile1${string}`) - : undefined - profilePubkey = value?.data.pubkey + // Decode if it starts with nprofile1 + if (nprofile?.startsWith('nprofile1')) { + const value = nprofile + ? nip19.decode(nprofile as `nprofile1${string}`) + : undefined + profilePubkey = value?.data.pubkey + } else if (nprofile?.startsWith('npub1')) { + // Try to get hex from the npub and encode it to nprofile + const value = npubToHex(nprofile) + if (value) { + return redirect( + getProfilePageRoute( + nip19.nprofileEncode({ + pubkey: value + }) + ) + ) + } + } } catch (error) { // Silently ignore and redirect to home or logged in user log(true, LogType.Error, 'Failed to decode nprofile.', error) @@ -57,6 +73,7 @@ export const profileRouteLoader = // Empty result const result: ProfilePageLoaderResult = { + profilePubkey: profilePubkey, profile: {}, isBlocked: false, isOwnProfile: false, diff --git a/src/pages/write/action.tsx b/src/pages/write/action.tsx index 447a605..61bb7ad 100644 --- a/src/pages/write/action.tsx +++ b/src/pages/write/action.tsx @@ -84,22 +84,27 @@ export const writeRouteAction = .split(',') .map((t) => ['t', t]) + const tags = [ + ['d', uuid], + ['a', aTag], + ['r', rTag], + ['published_at', published_at.toString()], + ['title', formSubmit.title!], + ['image', formSubmit.image!], + ['summary', formSubmit.summary!], + ...tTags + ] + + // Add NSFW tag, L label namespace standardized tag + // https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags + if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning']) + const unsignedEvent: UnsignedEvent = { kind: kinds.LongFormArticle, created_at: currentTimeStamp, pubkey: hexPubkey, content: content, - tags: [ - ['d', uuid], - ['a', aTag], - ['r', rTag], - ['published_at', published_at.toString()], - ['title', formSubmit.title!], - ['image', formSubmit.image!], - ['summary', formSubmit.summary!], - ['nsfw', (formSubmit.nsfw === 'on').toString()], - ...tTags - ] + tags: tags } try { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5c14fd0..aec86c6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -103,13 +103,15 @@ export const routerWithNdkContext = (context: NDKContextType) => path: appRoutes.blog, element: , loader: blogRouteLoader(context), - action: blogRouteAction(context) + action: blogRouteAction(context), + errorElement: }, { path: appRoutes.blogEdit, element: , loader: blogRouteLoader(context), - action: writeRouteAction(context) + action: writeRouteAction(context), + errorElement: }, { path: appRoutes.blogReport_actionOnly, diff --git a/src/styles/dotsSpinner.module.scss b/src/styles/dotsSpinner.module.scss new file mode 100644 index 0000000..772dfa8 --- /dev/null +++ b/src/styles/dotsSpinner.module.scss @@ -0,0 +1,18 @@ +.loading::after { + content: '.'; + animation: dots 1.5s steps(4, end) infinite; +} + +@keyframes dots { + 0%, + 20% { + content: '.\00a0\00a0'; + } + 40% { + content: '..\00a0'; + } + 60%, + 100% { + content: '...'; + } +} diff --git a/src/utils/blog.ts b/src/utils/blog.ts index df11b63..089e058 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -3,22 +3,36 @@ import { BlogCardDetails, BlogDetails } from 'types' import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr' import { kinds, nip19 } from 'nostr-tools' -export const extractBlogDetails = (event: NDKEvent): Partial => ({ - title: getFirstTagValue(event, 'title'), - content: event.content, - summary: getFirstTagValue(event, 'summary'), - image: getFirstTagValue(event, 'image'), - nsfw: getFirstTagValue(event, 'nsfw') === 'true', +export const extractBlogDetails = (event: NDKEvent): Partial => { + const dTag = getFirstTagValue(event, 'd') - id: event.id, - author: event.pubkey, - published_at: getFirstTagValueAsInt(event, 'published_at'), - edited_at: event.created_at, - rTag: getFirstTagValue(event, 'r') || 'N/A', - dTag: getFirstTagValue(event, 'd'), - aTag: getFirstTagValue(event, 'a'), - tTags: getTagValues(event, 't') || [] -}) + // Check if the aTag exists on the blog + let aTag = getFirstTagValue(event, 'a') + + // Create aTag from components if aTag is not included + if (typeof aTag === 'undefined' && event.pubkey && dTag) { + aTag = `${kinds.LongFormArticle}:${event.pubkey}:${dTag}` + } + + return { + title: getFirstTagValue(event, 'title'), + content: event.content, + summary: getFirstTagValue(event, 'summary'), + image: getFirstTagValue(event, 'image'), + // Check L label namespace for content warning or nsfw (backwards compatibility) + nsfw: + getFirstTagValue(event, 'L') === 'content-warning' || + getFirstTagValue(event, 'nsfw') === 'true', + id: event.id, + author: event.pubkey, + published_at: getFirstTagValueAsInt(event, 'published_at'), + edited_at: event.created_at, + rTag: getFirstTagValue(event, 'r') || 'N/A', + dTag: dTag, + aTag: aTag, + tTags: getTagValues(event, 't') || [] + } +} export const extractBlogCardDetails = ( event: NDKEvent