13 changed files with 390 additions and 15 deletions
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
import { useToast } from '@/hooks' |
||||
import { useBookmarks } from '@/providers/BookmarksProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { BookmarkIcon, Loader } from 'lucide-react' |
||||
import { useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Event } from 'nostr-tools' |
||||
|
||||
export default function BookmarkButton({ event }: { event: Event }) { |
||||
const { t } = useTranslation() |
||||
const { toast } = useToast() |
||||
const { pubkey: accountPubkey, checkLogin } = useNostr() |
||||
const { bookmarks, addBookmark, removeBookmark } = useBookmarks() |
||||
const [updating, setUpdating] = useState(false) |
||||
|
||||
const eventId = event.id |
||||
const eventPubkey = event.pubkey |
||||
|
||||
const isBookmarked = useMemo( |
||||
() => bookmarks.some((tag) => tag[0] === 'e' && tag[1] === eventId), |
||||
[bookmarks, eventId] |
||||
) |
||||
|
||||
if (!accountPubkey) return null |
||||
|
||||
const handleBookmark = async (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
checkLogin(async () => { |
||||
if (isBookmarked) return |
||||
|
||||
setUpdating(true) |
||||
try { |
||||
await addBookmark(eventId, eventPubkey) |
||||
toast({ |
||||
title: t('Note bookmarked'), |
||||
description: t('This note has been added to your bookmarks') |
||||
}) |
||||
} catch (error) { |
||||
toast({ |
||||
title: t('Bookmark failed'), |
||||
description: (error as Error).message, |
||||
variant: 'destructive' |
||||
}) |
||||
} finally { |
||||
setUpdating(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const handleRemoveBookmark = async (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
checkLogin(async () => { |
||||
if (!isBookmarked) return |
||||
|
||||
setUpdating(true) |
||||
try { |
||||
await removeBookmark(eventId) |
||||
toast({ |
||||
title: t('Bookmark removed'), |
||||
description: t('This note has been removed from your bookmarks') |
||||
}) |
||||
} catch (error) { |
||||
toast({ |
||||
title: t('Remove bookmark failed'), |
||||
description: (error as Error).message, |
||||
variant: 'destructive' |
||||
}) |
||||
} finally { |
||||
setUpdating(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
return ( |
||||
<button |
||||
className={`flex items-center gap-1 ${ |
||||
isBookmarked ? 'text-primary' : 'text-muted-foreground' |
||||
} enabled:hover:text-primary px-3 h-full`}
|
||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark} |
||||
disabled={updating} |
||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} |
||||
> |
||||
{updating ? ( |
||||
<Loader className="animate-spin" /> |
||||
) : ( |
||||
<BookmarkIcon className={isBookmarked ? 'fill-primary' : ''} /> |
||||
)} |
||||
</button> |
||||
) |
||||
} |
||||
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
import { useFetchEvent } from '@/hooks' |
||||
import { useBookmarks } from '@/providers/BookmarksProvider' |
||||
import { useEffect, useMemo, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { generateEventIdFromETag } from '@/lib/tag' |
||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' |
||||
|
||||
export default function BookmarksList() { |
||||
const { t } = useTranslation() |
||||
const { bookmarks } = useBookmarks() |
||||
const [visibleBookmarks, setVisibleBookmarks] = useState< |
||||
{ eventId: string; neventId?: string }[] |
||||
>([]) |
||||
const [loading, setLoading] = useState(true) |
||||
const bottomRef = useRef<HTMLDivElement | null>(null) |
||||
const SHOW_COUNT = 10 |
||||
|
||||
const bookmarkItems = useMemo(() => { |
||||
return bookmarks |
||||
.filter((tag) => tag[0] === 'e') |
||||
.map((tag) => ({ |
||||
eventId: tag[1], |
||||
neventId: generateEventIdFromETag(tag) |
||||
})) |
||||
.reverse() |
||||
}, [bookmarks]) |
||||
|
||||
useEffect(() => { |
||||
setVisibleBookmarks(bookmarkItems.slice(0, SHOW_COUNT)) |
||||
setLoading(false) |
||||
}, [bookmarkItems]) |
||||
|
||||
useEffect(() => { |
||||
const options = { |
||||
root: null, |
||||
rootMargin: '10px', |
||||
threshold: 0.1 |
||||
} |
||||
|
||||
const loadMore = () => { |
||||
if (visibleBookmarks.length < bookmarkItems.length) { |
||||
setVisibleBookmarks((prev) => [ |
||||
...prev, |
||||
...bookmarkItems.slice(prev.length, prev.length + SHOW_COUNT) |
||||
]) |
||||
} |
||||
} |
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => { |
||||
if (entries[0].isIntersecting) { |
||||
loadMore() |
||||
} |
||||
}, options) |
||||
|
||||
const currentBottomRef = bottomRef.current |
||||
|
||||
if (currentBottomRef) { |
||||
observerInstance.observe(currentBottomRef) |
||||
} |
||||
|
||||
return () => { |
||||
if (observerInstance && currentBottomRef) { |
||||
observerInstance.unobserve(currentBottomRef) |
||||
} |
||||
} |
||||
}, [visibleBookmarks, bookmarkItems]) |
||||
|
||||
if (loading) { |
||||
return <NoteCardLoadingSkeleton isPictures={false} /> |
||||
} |
||||
|
||||
if (bookmarkItems.length === 0) { |
||||
return ( |
||||
<div className="mt-2 text-sm text-center text-muted-foreground"> |
||||
{t('No bookmarks found. Add some by clicking the bookmark icon on notes.')} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-4"> |
||||
{visibleBookmarks.map((item) => ( |
||||
<BookmarkedNote key={item.eventId} eventId={item.eventId} neventId={item.neventId} /> |
||||
))} |
||||
|
||||
{visibleBookmarks.length < bookmarkItems.length && ( |
||||
<div ref={bottomRef}> |
||||
<NoteCardLoadingSkeleton isPictures={false} /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function BookmarkedNote({ eventId, neventId }: { eventId: string; neventId?: string }) { |
||||
const { event, isFetching } = useFetchEvent(neventId || eventId) |
||||
|
||||
if (isFetching) { |
||||
return <NoteCardLoadingSkeleton isPictures={false} /> |
||||
} |
||||
|
||||
if (!event) { |
||||
return null |
||||
} |
||||
|
||||
return <NoteCard event={event} className="w-full" /> |
||||
} |
||||
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
import { createBookmarkDraftEvent } from '@/lib/draft-event' |
||||
import { createContext, useContext, useMemo } from 'react' |
||||
import { useNostr } from './NostrProvider' |
||||
import client from '@/services/client.service' |
||||
|
||||
type TBookmarksContext = { |
||||
bookmarks: string[][] |
||||
addBookmark: (eventId: string, eventPubkey: string, relayHint?: string) => Promise<void> |
||||
removeBookmark: (eventId: string) => Promise<void> |
||||
} |
||||
|
||||
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined) |
||||
|
||||
export const useBookmarks = () => { |
||||
const context = useContext(BookmarksContext) |
||||
if (!context) { |
||||
throw new Error('useBookmarks must be used within a BookmarksProvider') |
||||
} |
||||
return context |
||||
} |
||||
|
||||
export function BookmarksProvider({ children }: { children: React.ReactNode }) { |
||||
const { pubkey: accountPubkey, bookmarkListEvent, publish, updateBookmarkListEvent } = useNostr() |
||||
const bookmarks = useMemo( |
||||
() => (bookmarkListEvent ? bookmarkListEvent.tags : []), |
||||
[bookmarkListEvent] |
||||
) |
||||
|
||||
const addBookmark = async (eventId: string, eventPubkey: string, relayHint?: string) => { |
||||
if (!accountPubkey) return |
||||
|
||||
const relayHintToUse = relayHint || client.getEventHint(eventId) |
||||
|
||||
const newTag = ['e', eventId, relayHintToUse, eventPubkey] |
||||
|
||||
const currentTags = bookmarkListEvent?.tags || [] |
||||
|
||||
const isDuplicate = currentTags.some((tag) => tag[0] === 'e' && tag[1] === eventId) |
||||
|
||||
if (isDuplicate) return |
||||
|
||||
const newTags = [...currentTags, newTag] |
||||
|
||||
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags) |
||||
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
||||
await updateBookmarkListEvent(newBookmarkEvent) |
||||
} |
||||
|
||||
const removeBookmark = async (eventId: string) => { |
||||
if (!accountPubkey || !bookmarkListEvent) return |
||||
|
||||
const newTags = bookmarkListEvent.tags.filter((tag) => !(tag[0] === 'e' && tag[1] === eventId)) |
||||
|
||||
if (newTags.length === bookmarkListEvent.tags.length) return |
||||
|
||||
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags) |
||||
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
||||
await updateBookmarkListEvent(newBookmarkEvent) |
||||
} |
||||
|
||||
return ( |
||||
<BookmarksContext.Provider |
||||
value={{ |
||||
bookmarks, |
||||
addBookmark, |
||||
removeBookmark |
||||
}} |
||||
> |
||||
{children} |
||||
</BookmarksContext.Provider> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue