13 changed files with 390 additions and 15 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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