From 7876f26d0c7fca9d3da1e047fa54b75484c456ff Mon Sep 17 00:00:00 2001 From: "M.Abubakar" <156602406+saithsab877@users.noreply.github.com> Date: Fri, 18 Apr 2025 18:51:36 +0500 Subject: [PATCH] feat: bookmarks (#279) --- src/App.tsx | 19 ++-- src/components/BookmarkButton/index.tsx | 90 +++++++++++++++ src/components/BookmarksList/index.tsx | 107 ++++++++++++++++++ src/components/FeedSwitcher/index.tsx | 23 +++- src/components/NoteStats/index.tsx | 3 + src/lib/draft-event.ts | 9 ++ src/pages/primary/NoteListPage/FeedButton.tsx | 13 ++- src/pages/primary/NoteListPage/index.tsx | 13 +++ src/providers/BookmarksProvider.tsx | 72 ++++++++++++ src/providers/FeedProvider.tsx | 14 +++ src/providers/NostrProvider/index.tsx | 30 ++++- src/services/indexed-db.service.ts | 10 +- src/types.ts | 2 +- 13 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 src/components/BookmarkButton/index.tsx create mode 100644 src/components/BookmarksList/index.tsx create mode 100644 src/providers/BookmarksProvider.tsx diff --git a/src/App.tsx b/src/App.tsx index 9cf19a12..bdbe009b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './index.css' import { Toaster } from '@/components/ui/toaster' import { ThemeProvider } from '@/providers/ThemeProvider' import { PageManager } from './PageManager' +import { BookmarksProvider } from './providers/BookmarksProvider' import { FavoriteRelaysProvider } from './providers/FavoriteRelaysProvider' import { FeedProvider } from './providers/FeedProvider' import { FollowListProvider } from './providers/FollowListProvider' @@ -23,14 +24,16 @@ export default function App(): JSX.Element { - - - - - - - - + + + + + + + + + + diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx new file mode 100644 index 00000000..6c8bf788 --- /dev/null +++ b/src/components/BookmarkButton/index.tsx @@ -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 ( + + ) +} diff --git a/src/components/BookmarksList/index.tsx b/src/components/BookmarksList/index.tsx new file mode 100644 index 00000000..b0d54a9e --- /dev/null +++ b/src/components/BookmarksList/index.tsx @@ -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(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 + } + + if (bookmarkItems.length === 0) { + return ( +
+ {t('No bookmarks found. Add some by clicking the bookmark icon on notes.')} +
+ ) + } + + return ( +
+ {visibleBookmarks.map((item) => ( + + ))} + + {visibleBookmarks.length < bookmarkItems.length && ( +
+ +
+ )} +
+ ) +} + +function BookmarkedNote({ eventId, neventId }: { eventId: string; neventId?: string }) { + const { event, isFetching } = useFetchEvent(neventId || eventId) + + if (isFetching) { + return + } + + if (!event) { + return null + } + + return +} diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx index 8aaea151..5550386c 100644 --- a/src/components/FeedSwitcher/index.tsx +++ b/src/components/FeedSwitcher/index.tsx @@ -1,6 +1,7 @@ import { toRelaySettings } from '@/lib/link' import { simplifyUrl } from '@/lib/url' import { SecondaryPageLink } from '@/PageManager' +import { useBookmarks } from '@/providers/BookmarksProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' @@ -8,11 +9,12 @@ import { useTranslation } from 'react-i18next' import RelayIcon from '../RelayIcon' import RelaySetCard from '../RelaySetCard' import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' -import { UsersRound } from 'lucide-react' +import { BookmarkIcon, UsersRound } from 'lucide-react' export default function FeedSwitcher({ close }: { close?: () => void }) { const { t } = useTranslation() const { pubkey } = useNostr() + const { bookmarks } = useBookmarks() const { relaySets, favoriteRelays } = useFavoriteRelays() const { feedInfo, switchFeed, temporaryRelayUrls } = useFeed() @@ -35,6 +37,25 @@ export default function FeedSwitcher({ close }: { close?: () => void }) { )} + + {pubkey && bookmarks.length > 0 && ( + { + if (!pubkey) return + switchFeed('bookmarks', { pubkey }) + close?.() + }} + > +
+
+ +
+
{t('Bookmarks')}
+
+
+ )} + {temporaryRelayUrls.length > 0 && ( + @@ -63,6 +65,7 @@ export default function NoteStats({ +
e.stopPropagation()}> diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 4796fa1a..6ffb084d 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -283,6 +283,15 @@ export function createSeenNotificationsAtDraftEvent(): TDraftEvent { } } +export function createBookmarkDraftEvent(tags: string[][]): TDraftEvent { + return { + kind: kinds.BookmarkList, + content: '', + tags, + created_at: dayjs().unix() + } +} + function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) { return imageUrls.map((imageUrl) => { const pictureInfo = pictureInfos.find((info) => info.url === imageUrl) diff --git a/src/pages/primary/NoteListPage/FeedButton.tsx b/src/pages/primary/NoteListPage/FeedButton.tsx index d24a9202..033f4e73 100644 --- a/src/pages/primary/NoteListPage/FeedButton.tsx +++ b/src/pages/primary/NoteListPage/FeedButton.tsx @@ -6,7 +6,7 @@ import { cn } from '@/lib/utils' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFeed } from '@/providers/FeedProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { ChevronDown, Server, UsersRound } from 'lucide-react' +import { BookmarkIcon, ChevronDown, Server, UsersRound } from 'lucide-react' import { forwardRef, HTMLAttributes, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -55,6 +55,9 @@ const FeedSwitcherTrigger = forwardRef - {feedInfo.feedType === 'following' ? : } + {feedInfo.feedType === 'following' ? ( + + ) : feedInfo.feedType === 'bookmarks' ? ( + + ) : ( + + )}
{title}
diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 79eee4d6..88e2b313 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -1,3 +1,4 @@ +import BookmarksList from '@/components/BookmarksList' import NoteList from '@/components/NoteList' import PostEditor from '@/components/PostEditor' import SaveRelayDropdownMenu from '@/components/SaveRelayDropdownMenu' @@ -35,6 +36,18 @@ const NoteListPage = forwardRef((_, ref) => { ) + } else if (feedInfo.feedType === 'bookmarks') { + if (!pubkey) { + content = ( +
+ +
+ ) + } else { + content = + } } else if (isReady) { content = ( Promise + removeBookmark: (eventId: string) => Promise +} + +const BookmarksContext = createContext(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 ( + + {children} + + ) +} diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 3ef61024..79241045 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -170,6 +170,20 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { }) return setIsReady(true) } + if (feedType === 'bookmarks') { + if (!options.pubkey) { + return setIsReady(true) + } + + const newFeedInfo = { feedType } + setFeedInfo(newFeedInfo) + feedInfoRef.current = newFeedInfo + storage.setFeedInfo(newFeedInfo, pubkey) + + setRelayUrls([]) + setFilter({}) + return setIsReady(true) + } if (feedType === 'temporary') { const urls = options.temporaryRelayUrls ?? temporaryRelayUrls if (!urls.length) { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index ea77c280..0e134302 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -33,6 +33,7 @@ type TNostrContext = { relayList: TRelayList | null followListEvent?: Event muteListEvent?: Event + bookmarkListEvent?: Event favoriteRelaysEvent: Event | null notificationsSeenAt: number account: TAccountPointer | null @@ -60,6 +61,7 @@ type TNostrContext = { updateProfileEvent: (profileEvent: Event) => Promise updateFollowListEvent: (followListEvent: Event) => Promise updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise + updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise updateNotificationsSeenAt: () => Promise } @@ -87,6 +89,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [relayList, setRelayList] = useState(null) const [followListEvent, setFollowListEvent] = useState(undefined) const [muteListEvent, setMuteListEvent] = useState(undefined) + const [bookmarkListEvent, setBookmarkListEvent] = useState(undefined) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null) const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1) const [isInitialized, setIsInitialized] = useState(false) @@ -149,12 +152,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { storedProfileEvent, storedFollowListEvent, storedMuteListEvent, + storedBookmarkListEvent, storedFavoriteRelaysEvent ] = await Promise.all([ indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata), indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts), indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist), + indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS) ]) if (storedRelayListEvent) { @@ -172,6 +177,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (storedMuteListEvent) { setMuteListEvent(storedMuteListEvent) } + if (storedBookmarkListEvent) { + setBookmarkListEvent(storedBookmarkListEvent) + } if (storedFavoriteRelaysEvent) { setFavoriteRelaysEvent(storedFavoriteRelaysEvent) } @@ -190,7 +198,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), [ { - kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS], + kinds: [ + kinds.Metadata, + kinds.Contacts, + kinds.Mutelist, + kinds.BookmarkList, + ExtendedKind.FAVORITE_RELAYS + ], authors: [account.pubkey] }, { @@ -203,6 +217,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) + const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList) const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS) const notificationsSeenAtEvent = sortedEvents.find( (e) => @@ -227,6 +242,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setMuteListEvent(muteListEvent) await indexedDb.putReplaceableEvent(muteListEvent) } + if (bookmarkListEvent) { + setBookmarkListEvent(bookmarkListEvent) + await indexedDb.putReplaceableEvent(bookmarkListEvent) + } if (favoriteRelaysEvent) { setFavoriteRelaysEvent(favoriteRelaysEvent) await indexedDb.putReplaceableEvent(favoriteRelaysEvent) @@ -563,6 +582,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setMuteListEvent(muteListEvent) } + const updateBookmarkListEvent = async (bookmarkListEvent: Event) => { + const newBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent) + if (newBookmarkListEvent.id !== bookmarkListEvent.id) return + + setBookmarkListEvent(newBookmarkListEvent) + } + const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return @@ -591,6 +617,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { relayList, followListEvent, muteListEvent, + bookmarkListEvent, favoriteRelaysEvent, notificationsSeenAt, account, @@ -617,6 +644,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { updateProfileEvent, updateFollowListEvent, updateMuteListEvent, + updateBookmarkListEvent, updateFavoriteRelaysEvent, updateNotificationsSeenAt }} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 7117c291..6cc3a0f2 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -13,6 +13,7 @@ const StoreNames = { RELAY_LIST_EVENTS: 'relayListEvents', FOLLOW_LIST_EVENTS: 'followListEvents', MUTE_LIST_EVENTS: 'muteListEvents', + BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', RELAY_INFO_EVENTS: 'relayInfoEvents', FAVORITE_RELAYS: 'favoriteRelays', @@ -35,7 +36,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 3) + const request = window.indexedDB.open('jumble', 4) request.onerror = (event) => { reject(event) @@ -60,6 +61,9 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) { db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' }) } + if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { + db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) + } if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) } @@ -335,6 +339,8 @@ class IndexedDbService { return StoreNames.RELAY_SETS case ExtendedKind.FAVORITE_RELAYS: return StoreNames.FAVORITE_RELAYS + case kinds.BookmarkList: + return StoreNames.BOOKMARK_LIST_EVENTS default: return undefined } @@ -360,7 +366,7 @@ class IndexedDbService { { name: StoreNames.FOLLOW_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 - } // 1 day + } ] const transaction = this.db!.transaction( stores.map((store) => store.name), diff --git a/src/types.ts b/src/types.ts index 43a8ae9d..27c04dea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,7 +94,7 @@ export type TAccount = { export type TAccountPointer = Pick -export type TFeedType = 'following' | 'relays' | 'relay' | 'temporary' +export type TFeedType = 'following' | 'relays' | 'relay' | 'temporary' | 'bookmarks' export type TFeedInfo = { feedType: TFeedType; id?: string } export type TLanguage = 'en' | 'zh' | 'pl'