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:
+2
-2
@@ -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 */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user