diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 370e246..4896396 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -5,6 +5,7 @@ import Index from "./pages/Index"; import { NIP19Page } from "./pages/NIP19Page"; import { Upload } from "./pages/Upload"; import { Hashtags } from "./pages/Hashtags"; +import { TagPage } from "./pages/TagPage"; import { SearchPage } from "./pages/Search"; import { Notifications } from "./pages/Notifications"; import Messages from "./pages/Messages"; @@ -19,6 +20,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/feed/AdaptivePictureCard.tsx b/src/components/feed/AdaptivePictureCard.tsx new file mode 100644 index 0000000..174f617 --- /dev/null +++ b/src/components/feed/AdaptivePictureCard.tsx @@ -0,0 +1,25 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { PictureCard } from './PictureCard'; +import { MinimalPictureCard } from './MinimalPictureCard'; +import { useAppContext } from '@/hooks/useAppContext'; + +interface AdaptivePictureCardProps { + event: NostrEvent; +} + +/** + * A smart component that renders either PictureCard or MinimalPictureCard + * based on the user's preferMinimalCards setting in AppContext. + * + * This allows for consistent card rendering across the app without + * needing to manually check the preference in every component. + */ +export function AdaptivePictureCard({ event }: AdaptivePictureCardProps) { + const { config } = useAppContext(); + + if (config.preferMinimalCards) { + return ; + } + + return ; +} diff --git a/src/hooks/useHashtagFeed.ts b/src/hooks/useHashtagFeed.ts new file mode 100644 index 0000000..6c0be5f --- /dev/null +++ b/src/hooks/useHashtagFeed.ts @@ -0,0 +1,33 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Hook for fetching kind 20 (NIP-68) picture events filtered by hashtag with infinite scroll + */ +export function useHashtagFeed(hashtag: string) { + const { nostr } = useNostr(); + const normalizedTag = hashtag.toLowerCase(); + + return useInfiniteQuery({ + queryKey: ['hashtag-feed', normalizedTag], + queryFn: async ({ pageParam, signal }) => { + const filter = pageParam + ? { kinds: [20], '#t': [normalizedTag], limit: 20, until: pageParam as number } + : { kinds: [20], '#t': [normalizedTag], limit: 20 }; + + const events = await nostr.query([filter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) + }); + + return events as NostrEvent[]; + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + // Subtract 1 since 'until' is inclusive + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: !!hashtag, + }); +} diff --git a/src/hooks/usePopularHashtags.ts b/src/hooks/usePopularHashtags.ts new file mode 100644 index 0000000..e63be7c --- /dev/null +++ b/src/hooks/usePopularHashtags.ts @@ -0,0 +1,50 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface HashtagCount { + tag: string; + count: number; +} + +/** + * Hook for fetching popular hashtags from kind 20 (NIP-68) picture events + * Returns hashtags sorted by frequency + */ +export function usePopularHashtags(limit: number = 50) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['popular-hashtags', limit], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(2000)]); + + // Query recent kind 20 events to extract hashtags + const events = await nostr.query([{ kinds: [20], limit: 500 }], { signal }); + + // Count hashtag occurrences + const hashtagMap = new Map(); + + events.forEach((event: NostrEvent) => { + // Extract 't' tags (hashtags) + const hashtags = event.tags.filter(([name]) => name === 't'); + + hashtags.forEach(([_, tag]) => { + if (tag) { + const normalizedTag = tag.toLowerCase(); + hashtagMap.set(normalizedTag, (hashtagMap.get(normalizedTag) || 0) + 1); + } + }); + }); + + // Convert to array and sort by count + const sortedHashtags: HashtagCount[] = Array.from(hashtagMap.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + return sortedHashtags; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} diff --git a/src/pages/Hashtags.tsx b/src/pages/Hashtags.tsx index b51e33b..b13e606 100644 --- a/src/pages/Hashtags.tsx +++ b/src/pages/Hashtags.tsx @@ -1,12 +1,89 @@ import { Layout } from '@/components/Layout'; +import { usePopularHashtags } from '@/hooks/usePopularHashtags'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Hash } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; export function Hashtags() { + const { data: hashtags, isLoading, isError } = usePopularHashtags(9); + const navigate = useNavigate(); + return (
-
-

Hashtags

-

Hashtags page - coming soon

+
+
+

Popular Hashtags

+

+ Discover trending topics and explore content by hashtag +

+
+ + {isLoading && ( +
+ {Array.from({ length: 9 }).map((_, i) => ( + + + + + + + + + ))} +
+ )} + + {isError && ( + + +
+

+ Failed to load hashtags. Try checking your relay connections or wait a moment. +

+
+
+
+ )} + + {!isLoading && !isError && hashtags && hashtags.length === 0 && ( + + +
+ +

+ No hashtags found yet. Be the first to add hashtags to your pictures! +

+
+
+
+ )} + + {!isLoading && !isError && hashtags && hashtags.length > 0 && ( +
+ {hashtags.map(({ tag, count }) => ( + navigate(`/tag/${encodeURIComponent(tag)}`)} + > + + + + {tag} + + + + + {count} {count === 1 ? 'post' : 'posts'} + + + + ))} +
+ )}
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 7128dbf..fb6b8e7 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -2,13 +2,11 @@ import { useSeoMeta } from '@unhead/react'; import { Layout } from '@/components/Layout'; import { usePictureFeed } from '@/hooks/usePictureFeed'; import { useFollowerPictureFeed } from '@/hooks/useFollowerPictureFeed'; -import { PictureCard } from '@/components/feed/PictureCard'; -import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard'; +import { AdaptivePictureCard } from '@/components/feed/AdaptivePictureCard'; import { useInView } from 'react-intersection-observer'; import { useEffect, useMemo } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent } from '@/components/ui/card'; -import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; const Index = () => { @@ -17,7 +15,6 @@ const Index = () => { description: 'Discover amazing pictures shared on Nostr.', }); - const { config } = useAppContext(); const { user } = useCurrentUser(); // Use follower feed for logged-in users, global feed for logged-out users @@ -83,11 +80,7 @@ const Index = () => { // Picture feed <> {pictures.map((picture) => ( - config.preferMinimalCards ? ( - - ) : ( - - ) + ))} {/* Infinite scroll trigger */} diff --git a/src/pages/ProfileView.tsx b/src/pages/ProfileView.tsx index e79d9c0..3ebcbc3 100644 --- a/src/pages/ProfileView.tsx +++ b/src/pages/ProfileView.tsx @@ -4,24 +4,21 @@ import { useUserNotes } from '@/hooks/useUserNotes'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { Loader2, Grid3x3, LayoutGrid, FileText } from 'lucide-react'; +import { Loader2, Image, FileText } from 'lucide-react'; import { ProfileHeader } from '@/components/profile/ProfileHeader'; -import { MinimalPictureCard } from '@/components/feed/MinimalPictureCard'; -import { PictureCard } from '@/components/feed/PictureCard'; +import { AdaptivePictureCard } from '@/components/feed/AdaptivePictureCard'; import { NoteCard } from '@/components/feed/NoteCard'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useState } from 'react'; export function ProfileView({ pubkey }: { pubkey: string }) { - const [activeTab, setActiveTab] = useState('minimal'); + const [activeTab, setActiveTab] = useState('pictures'); const author = useAuthor(pubkey); - const minimalPictures = useUserPictures(pubkey); - const fullPictures = useUserPictures(pubkey); + const pictures = useUserPictures(pubkey); const notes = useUserNotes(pubkey); - const minimalPicturesData = minimalPictures.data?.pages.flat() || []; - const fullPicturesData = fullPictures.data?.pages.flat() || []; + const picturesData = pictures.data?.pages.flat() || []; const notesData = notes.data?.pages.flat() || []; return ( @@ -36,68 +33,20 @@ export function ProfileView({ pubkey }: { pubkey: string }) { {/* Tabbed Content */} - - - + + + + Pictures - - - - + + Notes - {/* Minimal Pictures Tab */} - - {minimalPictures.isLoading ? ( -
- {[...Array(8)].map((_, i) => ( - - ))} -
- ) : minimalPicturesData.length === 0 ? ( - - -

- No pictures found for this user. -

-
-
- ) : ( - <> -
- {minimalPicturesData.map((event) => ( - - ))} -
- - {minimalPictures.hasNextPage && ( -
- -
- )} - - )} -
- - {/* Full Pictures Tab */} - - {fullPictures.isLoading ? ( + {/* Pictures Tab */} + + {pictures.isLoading ? (
{[...Array(6)].map((_, i) => ( @@ -109,7 +58,7 @@ export function ProfileView({ pubkey }: { pubkey: string }) { ))}
- ) : fullPicturesData.length === 0 ? ( + ) : picturesData.length === 0 ? (

@@ -120,20 +69,20 @@ export function ProfileView({ pubkey }: { pubkey: string }) { ) : ( <>

- {fullPicturesData.map((event) => ( - + {picturesData.map((event) => ( + ))}
- {fullPictures.hasNextPage && ( + {pictures.hasNextPage && (
+
+ +

{decodedTag}

+
+

+ Browse all pictures tagged with #{decodedTag} +

+
+ + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + ))} +
+ )} + + {isError && ( + + +
+

+ Failed to load pictures. Try checking your relay connections or wait a moment. +

+
+
+
+ )} + + {!isLoading && !isError && pictures.length === 0 && ( + + +
+ +

+ No pictures found with #{decodedTag}. Be the first to post one! +

+
+
+
+ )} + + {!isLoading && !isError && pictures.length > 0 && ( + <> +
+ {pictures.map((picture) => ( + + ))} +
+ + {/* Infinite scroll trigger */} +
+ {isFetchingNextPage && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + ))} +
+ )} +
+ + )} +
+ +
+ ); +}