From 589a60ec89cc56ac48ffdc5a9cb2bc29a7e1ec54 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 16 Feb 2026 17:46:37 -0600 Subject: [PATCH] Replace "More" with Bookmarks page - Created useBookmarks hook for NIP-51 kind 10003 bookmark list management (query, add, remove bookmarks) - Created BookmarksPage with login prompt, loading skeletons, and empty state - Replaced "More" nav item with "Bookmarks" in LeftSidebar - Added bookmark toggle button to NoteCard action bar (replaces three-dot menu) - Removed /more route from AppRouter - Bookmarks are stored per NIP-51 as replaceable kind 10003 events Co-authored-by: shakespeare.diy --- src/AppRouter.tsx | 4 +- src/components/LeftSidebar.tsx | 4 +- src/components/NoteCard.tsx | 23 +++++-- src/hooks/useBookmarks.ts | 106 +++++++++++++++++++++++++++++++++ src/pages/BookmarksPage.tsx | 101 +++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/hooks/useBookmarks.ts create mode 100644 src/pages/BookmarksPage.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 451823a3..61842be9 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -8,6 +8,7 @@ import { NotificationsPage } from "./pages/NotificationsPage"; import { SearchPage } from "./pages/SearchPage"; import { SettingsPage } from "./pages/SettingsPage"; import { HashtagPage } from "./pages/HashtagPage"; +import { BookmarksPage } from "./pages/BookmarksPage"; import { PlaceholderPage } from "./pages/PlaceholderPage"; import NotFound from "./pages/NotFound"; @@ -26,10 +27,9 @@ export function AppRouter() { } /> } /> } /> - } /> + } /> } /> } /> - } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index aaf9fcf2..c2dff16a 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom'; -import { Home, Bell, Search, Clapperboard, User, Wallet, Settings, MoreHorizontal } from 'lucide-react'; +import { Home, Bell, Search, Clapperboard, User, Wallet, Settings, Bookmark } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -42,7 +42,7 @@ export function LeftSidebar() { { to: '/profile', icon: , label: 'Profile' }, { to: '/wallet', icon: , label: 'Wallet' }, { to: '/settings', icon: , label: 'Settings' }, - { to: '/more', icon: , label: 'More' }, + { to: '/bookmarks', icon: , label: 'Bookmarks' }, ]; return ( diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 88370e61..048a91d0 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import { MessageCircle, Repeat2, Heart, Zap, MoreHorizontal } from 'lucide-react'; +import { MessageCircle, Repeat2, Heart, Zap, Bookmark } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { NoteContent } from '@/components/NoteContent'; import { useAuthor } from '@/hooks/useAuthor'; @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { nip19 } from 'nostr-tools'; import { useMemo, useState } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; +import { useBookmarks } from '@/hooks/useBookmarks'; interface NoteCardProps { event: NostrEvent; @@ -32,6 +33,8 @@ export function NoteCard({ event, className }: NoteCardProps) { const images = useMemo(() => extractImages(event.content), [event.content]); const { data: stats } = useEventStats(event.id); const [liked, setLiked] = useState(false); + const { isBookmarked, toggleBookmark } = useBookmarks(); + const bookmarked = isBookmarked(event.id); // Check if content is a reply const isReply = event.tags.some(([name]) => name === 'e'); @@ -165,13 +168,21 @@ export function NoteCard({ event, className }: NoteCardProps) { {stats?.zaps ? {stats.zaps} : null} - {/* More */} + {/* Bookmark */} diff --git a/src/hooks/useBookmarks.ts b/src/hooks/useBookmarks.ts new file mode 100644 index 00000000..e0c0da40 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -0,0 +1,106 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** Hook to manage the user's NIP-51 bookmark list (kind 10003). */ +export function useBookmarks() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutateAsync: publishEvent } = useNostrPublish(); + + // Query the user's bookmark list (kind 10003 — replaceable event) + const bookmarkListQuery = useQuery({ + queryKey: ['bookmarks', user?.pubkey], + queryFn: async () => { + if (!user) return null; + const events = await nostr.query([{ + kinds: [10003], + authors: [user.pubkey], + limit: 1, + }]); + return events[0] ?? null; + }, + enabled: !!user, + }); + + // Extract bookmarked event IDs from e tags + const bookmarkedIds: string[] = (bookmarkListQuery.data?.tags ?? []) + .filter(([name]) => name === 'e') + .map(([, id]) => id); + + // Query the actual bookmarked events + const bookmarkedEventsQuery = useQuery({ + queryKey: ['bookmarked-events', bookmarkedIds], + queryFn: async () => { + if (bookmarkedIds.length === 0) return []; + const events = await nostr.query([{ + ids: bookmarkedIds, + limit: bookmarkedIds.length, + }]); + // Sort to match bookmark order (most recently bookmarked first — last in tags = most recent) + const idOrder = [...bookmarkedIds].reverse(); + return events.sort((a, b) => { + const aIdx = idOrder.indexOf(a.id); + const bIdx = idOrder.indexOf(b.id); + return aIdx - bIdx; + }); + }, + enabled: bookmarkedIds.length > 0, + }); + + /** Check if an event is bookmarked. */ + function isBookmarked(eventId: string): boolean { + return bookmarkedIds.includes(eventId); + } + + /** Toggle bookmark for a given event. */ + const toggleBookmark = useMutation({ + mutationFn: async (eventId: string) => { + if (!user) throw new Error('User is not logged in'); + + const currentTags = bookmarkListQuery.data?.tags ?? []; + let newTags: string[][]; + + if (isBookmarked(eventId)) { + // Remove the bookmark + newTags = currentTags.filter( + ([name, id]) => !(name === 'e' && id === eventId) + ); + } else { + // Add the bookmark — append to end per NIP-51 recommendation + newTags = [...currentTags, ['e', eventId]]; + } + + await publishEvent({ + kind: 10003, + content: bookmarkListQuery.data?.content ?? '', + tags: newTags, + created_at: Math.floor(Date.now() / 1000), + } as Omit); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookmarks', user?.pubkey] }); + queryClient.invalidateQueries({ queryKey: ['bookmarked-events'] }); + }, + }); + + return { + /** The bookmark list event itself. */ + bookmarkList: bookmarkListQuery.data, + /** Array of bookmarked event IDs. */ + bookmarkedIds, + /** The actual bookmarked NostrEvents, ordered most-recently-bookmarked first. */ + events: bookmarkedEventsQuery.data ?? [], + /** Whether the bookmark list is loading. */ + isLoading: bookmarkListQuery.isLoading, + /** Whether the bookmarked events are loading. */ + isLoadingEvents: bookmarkedEventsQuery.isLoading, + /** Check if a specific event ID is bookmarked. */ + isBookmarked, + /** Toggle a bookmark on/off. Returns a mutation object. */ + toggleBookmark, + }; +} diff --git a/src/pages/BookmarksPage.tsx b/src/pages/BookmarksPage.tsx new file mode 100644 index 00000000..db6beb1e --- /dev/null +++ b/src/pages/BookmarksPage.tsx @@ -0,0 +1,101 @@ +import { useSeoMeta } from '@unhead/react'; +import { ArrowLeft, Bookmark } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { MainLayout } from '@/components/MainLayout'; +import { NoteCard } from '@/components/NoteCard'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent } from '@/components/ui/card'; +import { useBookmarks } from '@/hooks/useBookmarks'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { LoginArea } from '@/components/auth/LoginArea'; + +export function BookmarksPage() { + useSeoMeta({ + title: 'Bookmarks | Mew', + description: 'Your saved bookmarks on Nostr.', + }); + + const { user } = useCurrentUser(); + const { events, isLoading, isLoadingEvents, bookmarkedIds } = useBookmarks(); + + return ( + +
+ {/* Sticky header */} +
+ + + +

Bookmarks

+
+ + {/* Content */} + {!user ? ( +
+
+ +
+
+

Save posts for later

+

+ Log in to bookmark posts and find them here anytime. +

+
+ +
+ ) : isLoading || (bookmarkedIds.length > 0 && isLoadingEvents) ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : events.length > 0 ? ( +
+ {events.map((event) => ( + + ))} +
+ ) : ( +
+
+ +
+
+

No bookmarks yet

+

+ When you bookmark a post, it will show up here. Tap the bookmark icon on any post to save it. +

+
+
+ )} +
+
+ ); +} + +function BookmarkSkeleton() { + return ( +
+
+ +
+
+ + + +
+
+ + +
+
+ + + + +
+
+
+
+ ); +}