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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-16 17:46:37 -06:00
parent 9b92141b95
commit 589a60ec89
5 changed files with 228 additions and 10 deletions
+2 -2
View File
@@ -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() {
<Route path="/settings/:section" element={<SettingsPage />} />
<Route path="/vines" element={<PlaceholderPage title="Vines" />} />
<Route path="/wallet" element={<PlaceholderPage title="Wallet" />} />
<Route path="/bookmarks" element={<PlaceholderPage title="Bookmarks" />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/mutes" element={<PlaceholderPage title="Mutes" />} />
<Route path="/domain-blocks" element={<PlaceholderPage title="Domain blocks" />} />
<Route path="/more" element={<PlaceholderPage title="More" />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
+2 -2
View File
@@ -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: <User className="size-6" />, label: 'Profile' },
{ to: '/wallet', icon: <Wallet className="size-6" />, label: 'Wallet' },
{ to: '/settings', icon: <Settings className="size-6" />, label: 'Settings' },
{ to: '/more', icon: <MoreHorizontal className="size-6" />, label: 'More' },
{ to: '/bookmarks', icon: <Bookmark className="size-6" />, label: 'Bookmarks' },
];
return (
+17 -6
View File
@@ -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 ? <span className="text-xs">{stats.zaps}</span> : null}
</button>
{/* More */}
{/* Bookmark */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => e.stopPropagation()}
className={cn(
"p-2 rounded-full transition-colors",
bookmarked
? "text-primary"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
title={bookmarked ? "Remove bookmark" : "Bookmark"}
onClick={(e) => {
e.stopPropagation();
toggleBookmark.mutate(event.id);
}}
>
<MoreHorizontal className="size-[18px]" />
<Bookmark className={cn("size-[18px]", bookmarked && "fill-primary")} />
</button>
</div>
</div>
+106
View File
@@ -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<NostrEvent, 'id' | 'pubkey' | 'sig'>);
},
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,
};
}
+101
View File
@@ -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 (
<MainLayout hideMobileTopBar>
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l lg:border-r border-border min-h-screen">
{/* Sticky header */}
<div className="flex items-center gap-4 px-4 py-3 sticky top-0 bg-background/80 backdrop-blur-md z-10 border-b border-border">
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-xl font-bold">Bookmarks</h1>
</div>
{/* Content */}
{!user ? (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Bookmark className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">Save posts for later</h2>
<p className="text-muted-foreground text-sm">
Log in to bookmark posts and find them here anytime.
</p>
</div>
<LoginArea className="max-w-60" />
</div>
) : isLoading || (bookmarkedIds.length > 0 && isLoadingEvents) ? (
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<BookmarkSkeleton key={i} />
))}
</div>
) : events.length > 0 ? (
<div>
{events.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
) : (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-muted">
<Bookmark className="size-8 text-muted-foreground" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No bookmarks yet</h2>
<p className="text-muted-foreground text-sm">
When you bookmark a post, it will show up here. Tap the bookmark icon on any post to save it.
</p>
</div>
</div>
)}
</main>
</MainLayout>
);
}
function BookmarkSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-8" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex gap-12 mt-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
</div>
</div>
);
}