Repository: CodyTseng/jumble
Pull request #279: Add bookmark feature for notes with NIP-51 support
Author: login: saithsab877
Created: 2025-04-16T06:34:54Z | Merged: 2025-04-18T13:51:36Z

close #90

Demo:

https://www.loom.com/share/04d0a2cf1c334ee0ba77e520869b1ad6


Linked issue #90: Option to bookmark notes
Hi,

Would be very useful to have an option to bookmark notes. Even an option to bookmark in a private or public mode. 

Thanks.
Linked issue comments: user: login: CodyTseng
created_at: 2025-02-05T14:34:44Z
body: Received!
author_association: OWNER
user: login: vcavallo
created_at: 2025-03-04T14:15:00Z
body: Count me in for a 5,000 sats bonus when this is merged!
author_association: NONE
user: login: bitcoinuser
created_at: 2025-04-05T15:35:00Z
body: > Count me in for a 5,000 sats bonus when this is merged!

What about add this on: https://app.lightningbounties.com/
author_association: CONTRIBUTOR
user: login: DeMonkeyCoder
created_at: 2025-04-13T20:09:30Z
body: Hi,

I did some initial research on this, and seems like there's a standard for it:
https://github.com/nostr-protocol/nips/blob/master/51.md
and a sample implementation (it didn't work for me, but might be because I'm a beginner and made a mistake somewhere):
https://github.com/AsaiToshiya/nostr-toybox/tree/main/bookmark

Is it a good starting point? May I work on this? @bitcoinuser @CodyTseng 
author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-13T20:17:33Z
body: > Hi,
> 
> I did some initial research on this, and seems like there's a standard for it: https://github.com/nostr-protocol/nips/blob/master/51.md and a sample implementation (it didn't work for me, but might be because I'm a beginner and made a mistake somewhere): https://github.com/AsaiToshiya/nostr-toybox/tree/main/bookmark
> 
> Is it a good starting point? May I work on this? [@bitcoinuser](https://github.com/bitcoinuser) [@CodyTseng](https://github.com/CodyTseng)

Recently @CodyTseng wrote this : https://github.com/CodyTseng/jumble/issues/230#issuecomment-2799553170

I think if your work do not meet his expectations (it's high), he will not merge it. 
Many people try to create PRs just for the money and he doesn't want this kind of person. The person needs to like quality and like the project.
So if you think you are not the right person, better do not work on this.

author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-13T20:20:11Z
body: See this discussion: https://github.com/CodyTseng/jumble/issues/182#issuecomment-2799545688
author_association: CONTRIBUTOR
user: login: DeMonkeyCoder
created_at: 2025-04-13T22:09:10Z
body: > See this discussion: [#182 (comment)](https://github.com/CodyTseng/jumble/issues/182#issuecomment-2799545688)

Already saw that. as I said, I'm already interested in contributing and the bounty is not the only motivation for me :)



author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-14T06:44:37Z
body: NIP-51 defines two types of bookmarks: `kind:10003` (bookmarks) and `kind:30003` (bookmark sets). We can start by implementing only `kind:10003`, which doesn't involve folders or sets.

For events like `Bookmarks`, I usually initialize them in `/src/providers/NostrProvider/index.tsx`. You can refer to how events like `Metadata`, `Contacts`, and `MuteList` are initialized.

Then, I typically create a `BookmarksProvider` to handle updates to the `Bookmarks`. You can look at `FollowListProvider` and `MuteListProvider` as references.

The entry point can be placed in `/src/components/FeedSwitcher`.

![Image](https://github.com/user-attachments/assets/c8c90dbc-f5c7-4708-826b-2e108602998c)

If you run into any other issues, feel free to reach out to me anytime. However, due to the time difference, I might not be able to respond immediately. If you find anything in the project that seems poorly implemented, we can discuss and improve it together. I'm a backend developer and have only been using React for less than a year, so some of my approaches might be incorrect, feel free to point them out.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T06:45:26Z
body: > Count me in for a 5,000 sats bonus when this is merged!

@vcavallo Please review here https://github.com/CodyTseng/jumble/pull/279
author_association: CONTRIBUTOR
user: login: DeMonkeyCoder
created_at: 2025-04-16T07:29:15Z
body: @CodyTseng Please don't merge #279 to let this guy @saithsab877  learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T07:53:03Z
body: > [@CodyTseng](https://github.com/CodyTseng) Please don't merge [#279](https://github.com/CodyTseng/jumble/pull/279) to let this guy [@saithsab877](https://github.com/saithsab877) learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.

Sorry, I didn't notice the change of person. Take your time, I'll wait for your PR.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T08:12:24Z
body: > [@CodyTseng](https://github.com/CodyTseng) Please don't merge [#279](https://github.com/CodyTseng/jumble/pull/279) to let this guy [@saithsab877](https://github.com/saithsab877) learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.

@DeMonkeyCoder Just to clarify, this issue wasn’t actually assigned to you — you only expressed interest, but there was no follow-up request to get it assigned. It’s important we go by what’s actually confirmed to avoid misunderstandings.

@CodyTseng @bitcoinuser 
author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-16T08:17:56Z
body: > > [@CodyTseng](https://github.com/CodyTseng) Please don't merge [#279](https://github.com/CodyTseng/jumble/pull/279) to let this guy [@saithsab877](https://github.com/saithsab877) learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.
> 
> [@DeMonkeyCoder](https://github.com/DeMonkeyCoder) Just to clarify, this issue wasn’t actually assigned to you — you only expressed interest, but there was no follow-up request to get it assigned. It’s important we go by what’s actually confirmed to avoid misunderstandings.
> 
> [@CodyTseng](https://github.com/CodyTseng) [@bitcoinuser](https://github.com/bitcoinuser)

Hi @CodyTseng I think @saithsab877 has a point here. I think we should have assigned @DeMonkeyCoder  and have confirmation that he was really working on it. If you really liked @saithsab877 work I can pay him 15k sats and pay @DeMonkeyCoder 15k sats too. Maybe @DeMonkeyCoder can help improving something on code later if he finds something that can be improved.
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T08:22:16Z
body: @CodyTseng Thanks a lot, I really appreciate your fairness and support. I'm totally fine with this solution. Happy to collaborate if @DeMonkeyCoder finds anything to improve or build on further. Let’s keep things moving forward!
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T08:28:09Z
body: @DeMonkeyCoder Would you be interested in improving the bookmarks list display based on @saithsab877 's code? https://github.com/CodyTseng/jumble/pull/279#issuecomment-2808610975
author_association: OWNER
user: login: DeMonkeyCoder
created_at: 2025-04-16T08:42:28Z
body: > > [@CodyTseng](https://github.com/CodyTseng) Please don't merge [#279](https://github.com/CodyTseng/jumble/pull/279) to let this guy [@saithsab877](https://github.com/saithsab877) learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.
> 
> [@DeMonkeyCoder](https://github.com/DeMonkeyCoder) Just to clarify, this issue wasn’t actually assigned to you — you only expressed interest, but there was no follow-up request to get it assigned. It’s important we go by what’s actually confirmed to avoid misunderstandings.
> 
> [@CodyTseng](https://github.com/CodyTseng) [@bitcoinuser](https://github.com/bitcoinuser)

I asked "May I work on this?" and got my response and guide on how to move forward. What else is considered being "actually assigned"? signing a contract? :D

Just the same way that you said you spent a full day understanding and solving this issue, I spent time as well while looking for the standard and coming up with the initial solution and @CodyTseng's reply which provided a clear guide on how to implement this featrue. Then all you needed to do (and actually did) is to copy paste everything that was done for the other lists and rename it to BookmarkList. I think you didn't even bother to lookup for the BookmarkList event kind in the kinds object and went directly for adding an ExtendedKind. (I might be wrong on this one because I didn't go for a full implementation).
The difference between you and I is that I already notified people that I'm working on this to not let the others waste their time, and not have my time wasted as well.

I'm definitely not interested in improving this and be in a competition for money. I wanted to learn Nostr and play around a bit, which I did. The rest of this is not worth my time. If I was going to implement it myself I would've thought on the best way to implement things but not in this speed competition where it's in the interest of bounty hunters to do the bare minimum in the fastest way. (This is a fundamental problem in this bounty system, not about the bounty hunters)

@saithsab877 It seems that you did not create this problem intentionally, but please learn from today's mistake for your future bounty attempts and first make sure you're not wasting anyone else's time

@bitcoinuser @CodyTseng Feel free to do what you think is best. I'm fine with merging #279 if you see fit, and then I think I deserve a compensation for the initial research that I've done
author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-16T08:47:30Z
body: > > > [@CodyTseng](https://github.com/CodyTseng) Please don't merge [#279](https://github.com/CodyTseng/jumble/pull/279) to let this guy [@saithsab877](https://github.com/saithsab877) learn that when someone is already agreed to work on a bounty, it's better to first wait for them to respect their time. I'll finish the feature in a day.
> > 
> > 
> > [@DeMonkeyCoder](https://github.com/DeMonkeyCoder) Just to clarify, this issue wasn’t actually assigned to you — you only expressed interest, but there was no follow-up request to get it assigned. It’s important we go by what’s actually confirmed to avoid misunderstandings.
> > [@CodyTseng](https://github.com/CodyTseng) [@bitcoinuser](https://github.com/bitcoinuser)
> 
> I asked "May I work on this?" and got my response and guide on how to move forward. What else is considered being "actually assigned"? signing a contract? :D
> 
> Just the same way that you said you spent a full day understanding and solving this issue, I spent time as well while looking for the standard and coming up with the initial solution and [@CodyTseng](https://github.com/CodyTseng)'s reply which provided a clear guide on how to implement this featrue. Then all you needed to do (and actually did) is to copy paste everything that was done for the other lists and rename it to BookmarkList. I think you didn't even bother to lookup for the BookmarkList event kind in the kinds object and went directly for adding an ExtendedKind. (I might be wrong on this one because I didn't go for a full implementation). The difference between you and I is that I already notified people that I'm working on this to not let the others waste their time, and not have my time wasted as well.
> 
> I'm definitely not interested in improving this and be in a competition for money. I wanted to learn Nostr and play around a bit, which I did. The rest of this is not worth my time. If I was going to implement it myself I would've thought on the best way to implement things but not in this speed competition where it's in the interest of bounty hunters to do the bare minimum in the fastest way. (This is a fundamental problem in this bounty system, not about the bounty hunters)
> 
> [@saithsab877](https://github.com/saithsab877) It seems that you did not create this problem intentionally, but please learn from today's mistake for your future bounty attempts and first make sure you're not wasting anyone else's time
> 
> [@bitcoinuser](https://github.com/bitcoinuser) [@CodyTseng](https://github.com/CodyTseng) Feel free to do what you think is best. I'm fine with merging [#279](https://github.com/CodyTseng/jumble/pull/279) if you see fit, and then I think I deserve a compensation for the initial research that I've done

Hi, @DeMonkeyCoder I understand you and I think you were the only one correct in this situation. As I said, I'm ok paying you 15k sats too. 


author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-16T09:05:39Z
body: @DeMonkeyCoder please consider this: https://github.com/CodyTseng/jumble/pull/279#issuecomment-2808875769
And send me a lightning invoice for 15k 

Thanks.
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T09:15:31Z
body: @DeMonkeyCoder I’m really sorry that you had such an unpleasant experience, but I can tell that you have a deep understanding of nostr and this project. Going forward, we will make sure to clearly assign issues to those who want to contribute, to avoid similar situations.

This issue can be broken into two parts: one part is managing the Bookmarks event, and the other is the Bookmarks list component. Managing the Bookmarks event is almost complete, though there are still some minor issues, such as inconsistent button styles and redundant `ExtendedKind`.… So, the overall issue is about 50% done.

Would you be interested in continuing the development? If so, I can merge @saithsab877 's code into a new branch, and you can start developing from that branch. Does that sound good?
author_association: OWNER
user: login: DeMonkeyCoder
created_at: 2025-04-16T10:15:12Z
body: > [@DeMonkeyCoder](https://github.com/DeMonkeyCoder) I’m really sorry that you had such an unpleasant experience

No worries, that wasn't your fault, and thanks for helping me learn more about Nostr.

My suggestion is to let @saithsab877 complete their work and close this issue and claim their bounty, as I think it would be the more efficient option for now, then I might refactor and improve some parts later.
author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-18T13:59:39Z
body: > Count me in for a 5,000 sats bonus when this is merged!

Please pay @saithsab877 the 5000 sats. Thanks.
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-18T14:04:42Z
body: Also @bitcoinuser I get error during claiming bounty:

![Image](https://github.com/user-attachments/assets/36ea5f7d-b356-418a-af97-863deae37057)
author_association: CONTRIBUTOR
user: login: bitcoinuser
created_at: 2025-04-18T14:06:10Z
body: > Also [@bitcoinuser](https://github.com/bitcoinuser) I get error during claiming bounty:
> 
> ![Image](https://github.com/user-attachments/assets/36ea5f7d-b356-418a-af97-863deae37057)

cc @sutt
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-18T14:06:53Z
body: > Count me in for a 5,000 sats bonus when this is merged!

@vcavallo When you're free, please let me know so I can send you the invoice. Thank you!
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-18T14:07:33Z
body: > > Count me in for a 5,000 sats bonus when this is merged!
> 
> Please pay [@saithsab877](https://github.com/saithsab877) the 5000 sats. Thanks.

@bitcoinuser Thanks
author_association: CONTRIBUTOR
user: login: vcavallo
created_at: 2025-04-18T14:54:48Z
body: > > Count me in for a 5,000 sats bonus when this is merged!
> 
> [@vcavallo](https://github.com/vcavallo) When you're free, please let me know so I can send you the invoice. Thank you!

I added a 5,000 sat reward to the bounty here: https://app.lightningbounties.com/issue/c24ebc73-07be-47ee-895b-88e741583a5e I hope that's okay. this is my first time using the app.


author_association: NONE
user: login: saithsab877
created_at: 2025-04-18T15:08:07Z
body: > > > Count me in for a 5,000 sats bonus when this is merged!
> > 
> > 
> > [@vcavallo](https://github.com/vcavallo) When you're free, please let me know so I can send you the invoice. Thank you!
> 
> I added a 5,000 sat reward to the bounty here: https://app.lightningbounties.com/issue/c24ebc73-07be-47ee-895b-88e741583a5e I hope that's okay. this is my first time using the app.

@vcavallo Thanks
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-18T15:11:26Z
body: > Also [@bitcoinuser](https://github.com/bitcoinuser) I get error during claiming bounty:
> 
> ![Image](https://github.com/user-attachments/assets/36ea5f7d-b356-418a-af97-863deae37057)

@sutt Please review this issue — I think it needs to be paid manually.
author_association: CONTRIBUTOR
user: login: vcavallo
created_at: 2025-04-18T15:28:11Z
body: tangentially, this kind of thing was a major motivator for https://catallax.network ... seeing payments get "stuck" in a centralized platform upsets me. this could be happening permissionlessly over nostr zaps
author_association: NONE
user: login: sutt
created_at: 2025-04-19T15:24:54Z
body: Alright folks (@vcavallo / @saithsab877 @bitcoinuser), this should work when you click "Claim Reward".

Thanks for patience and helping us find the bug* added 2k sats.

*If you're curiousWe didn't anticipate PR's having some many comments so we had to account for pagination :)
author_association: NONE

Diff:
diff --git a/src/App.tsx b/src/App.tsx
index 9cf19a12d..bdbe009b2 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 {
             <FavoriteRelaysProvider>
               <FollowListProvider>
                 <MuteListProvider>
-                  <FeedProvider>
-                    <NoteStatsProvider>
-                      <MediaUploadServiceProvider>
-                        <PageManager />
-                        <Toaster />
-                      </MediaUploadServiceProvider>
-                    </NoteStatsProvider>
-                  </FeedProvider>
+                  <BookmarksProvider>
+                    <FeedProvider>
+                      <NoteStatsProvider>
+                        <MediaUploadServiceProvider>
+                          <PageManager />
+                          <Toaster />
+                        </MediaUploadServiceProvider>
+                      </NoteStatsProvider>
+                    </FeedProvider>
+                  </BookmarksProvider>
                 </MuteListProvider>
               </FollowListProvider>
             </FavoriteRelaysProvider>
diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx
new file mode 100644
index 000000000..6c8bf7882
--- /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 (
+    <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>
+  )
+}
diff --git a/src/components/BookmarksList/index.tsx b/src/components/BookmarksList/index.tsx
new file mode 100644
index 000000000..b0d54a9e5
--- /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<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" />
+}
diff --git a/src/components/FeedSwitcher/index.tsx b/src/components/FeedSwitcher/index.tsx
index 8aaea151e..5550386cb 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 }) {
           </div>
         </FeedSwitcherItem>
       )}
+
+      {pubkey && bookmarks.length > 0 && (
+        <FeedSwitcherItem
+          isActive={feedInfo.feedType === 'bookmarks'}
+          onClick={() => {
+            if (!pubkey) return
+            switchFeed('bookmarks', { pubkey })
+            close?.()
+          }}
+        >
+          <div className="flex gap-2 items-center">
+            <div className="flex justify-center items-center w-6 h-6 shrink-0">
+              <BookmarkIcon className="size-4" />
+            </div>
+            <div>{t('Bookmarks')}</div>
+          </div>
+        </FeedSwitcherItem>
+      )}
+
       {temporaryRelayUrls.length > 0 && (
         <FeedSwitcherItem
           key="temporary"
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index aa16ffb5a..8e3af788e 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -3,6 +3,7 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
 import { useScreenSize } from '@/providers/ScreenSizeProvider'
 import { Event } from 'nostr-tools'
 import { useEffect } from 'react'
+import BookmarkButton from '../BookmarkButton'
 import LikeButton from './LikeButton'
 import ReplyButton from './ReplyButton'
 import RepostButton from './RepostButton'
@@ -48,6 +49,7 @@ export default function NoteStats({
           <RepostButton event={event} />
           <LikeButton event={event} />
           <ZapButton event={event} />
+          <BookmarkButton event={event} />
           <SeenOnButton event={event} />
         </div>
       </div>
@@ -63,6 +65,7 @@ export default function NoteStats({
           <RepostButton event={event} />
           <LikeButton event={event} />
           <ZapButton event={event} />
+          <BookmarkButton event={event} />
         </div>
         <div className="flex items-center" onClick={(e) => e.stopPropagation()}>
           <SeenOnButton event={event} />
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index 4796fa1a4..6ffb084dc 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 d24a9202e..033f4e733 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<HTMLDivElement, HTMLAttributes<HTMLDivEle
       if (feedInfo.feedType === 'following') {
         return t('Following')
       }
+      if (feedInfo.feedType === 'bookmarks') {
+        return t('Bookmarks')
+      }
       if (relayUrls.length === 0) {
         return t('Choose a relay')
       }
@@ -77,7 +80,13 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
         ref={ref}
         {...props}
       >
-        {feedInfo.feedType === 'following' ? <UsersRound /> : <Server />}
+        {feedInfo.feedType === 'following' ? (
+          <UsersRound />
+        ) : feedInfo.feedType === 'bookmarks' ? (
+          <BookmarkIcon />
+        ) : (
+          <Server />
+        )}
         <div className="text-lg font-semibold truncate">{title}</div>
         <ChevronDown />
       </div>
diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx
index 79eee4d61..88e2b3132 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) => {
         </Button>
       </div>
     )
+  } else if (feedInfo.feedType === 'bookmarks') {
+    if (!pubkey) {
+      content = (
+        <div className="flex justify-center w-full">
+          <Button size="lg" onClick={() => checkLogin()}>
+            {t('Please login to view bookmarks')}
+          </Button>
+        </div>
+      )
+    } else {
+      content = <BookmarksList />
+    }
   } else if (isReady) {
     content = (
       <NoteList
diff --git a/src/providers/BookmarksProvider.tsx b/src/providers/BookmarksProvider.tsx
new file mode 100644
index 000000000..1935b18d9
--- /dev/null
+++ b/src/providers/BookmarksProvider.tsx
@@ -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>
+  )
+}
diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx
index 3ef61024f..79241045f 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 ea77c2805..0e1343020 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<void>
   updateFollowListEvent: (followListEvent: Event) => Promise<void>
   updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
+  updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
   updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
   updateNotificationsSeenAt: () => Promise<void>
 }
@@ -87,6 +89,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
   const [relayList, setRelayList] = useState<TRelayList | null>(null)
   const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
   const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
+  const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | undefined>(undefined)
   const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(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 7117c2919..6cc3a0f28 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<void> {
     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 fdc8b9aef..f8f0cbfab 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -94,7 +94,7 @@ export type TAccount = {
 
 export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
 
-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'


Reviews:
user: login: CodyTseng
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-16T12:55:15Z
user: login: saithsab877
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:14:36Z
user: login: CodyTseng
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-16T13:49:51Z
user: login: CodyTseng
body: (change review status)
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-04-16T13:53:20Z
user: login: CodyTseng
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T13:45:09Z
user: login: CodyTseng
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T13:45:50Z
user: login: CodyTseng
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-17T13:50:18Z
user: login: CodyTseng
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-18T07:12:27Z

Review comments:
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: Use `generateEventIdFromETag` from `/src/lib/tag.ts` to generate the `eventId`
created_at: 2025-04-16T12:19:23Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: Where did you see that the fifth element can be `private`?
created_at: 2025-04-16T12:22:07Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: relay hints are already included in the generated `eventId`, so it's not needed here.
created_at: 2025-04-16T12:23:24Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
+    )
+  }
+
+  if (bookmarkItems.length === 0) {
+    return (
+      <div className="p-4 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} isPrivate={item.private} />
+      ))}
+
+      {visibleBookmarks.length < bookmarkItems.length && (
+        <div className="flex justify-center py-4">
+          <button
+            className="px-4 py-2 rounded bg-primary/10 hover:bg-primary/20 text-primary"
+            onClick={loadMore}
+          >
+            {t('Load more')}
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+function BookmarkedNote({ eventId, isPrivate }: { eventId: string; isPrivate: boolean }) {
+  const { t } = useTranslation()
+  const { event, isFetching } = useFetchEvent(eventId)
+
+  if (isFetching) {
+    return (
+      <div className="p-4 border rounded animate-pulse">
+        <div className="h-4 bg-muted rounded w-1/3 mb-2"></div>
+        <div className="h-4 bg-muted rounded w-2/3"></div>
+      </div>
+    )
+  }
+
+  if (!event) {
+    return (
+      <div className="p-4 border rounded text-muted-foreground">
+        {t('Note not found or deleted')}
+      </div>
+    )
+  }
+
+  return (
+    <div className="p-4 border rounded">
+      {isPrivate && (
+        <div className="flex gap-1 items-center text-muted-foreground text-xs mb-2">
+          <LockIcon size={12} />
+          <span>{t('Private bookmark')}</span>
+        </div>
+      )}
+      <Note event={event} fetchNoteStats />
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: use `NoteCard`
created_at: 2025-04-16T12:26:38Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
+    )
+  }
+
+  if (bookmarkItems.length === 0) {
+    return (
+      <div className="p-4 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} isPrivate={item.private} />
+      ))}
+
+      {visibleBookmarks.length < bookmarkItems.length && (
+        <div className="flex justify-center py-4">
+          <button
+            className="px-4 py-2 rounded bg-primary/10 hover:bg-primary/20 text-primary"
+            onClick={loadMore}
+          >
+            {t('Load more')}
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+function BookmarkedNote({ eventId, isPrivate }: { eventId: string; isPrivate: boolean }) {
+  const { t } = useTranslation()
+  const { event, isFetching } = useFetchEvent(eventId)
+
+  if (isFetching) {
+    return (
+      <div className="p-4 border rounded animate-pulse">
+        <div className="h-4 bg-muted rounded w-1/3 mb-2"></div>
+        <div className="h-4 bg-muted rounded w-2/3"></div>
+      </div>
+    )
+  }
+
+  if (!event) {
+    return (
+      <div className="p-4 border rounded text-muted-foreground">
+        {t('Note not found or deleted')}
+      </div>
+    )
+  }
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: return null
created_at: 2025-04-16T12:27:34Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
+    )
+  }
+
+  if (bookmarkItems.length === 0) {
+    return (
+      <div className="p-4 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} isPrivate={item.private} />
+      ))}
+
+      {visibleBookmarks.length < bookmarkItems.length && (
+        <div className="flex justify-center py-4">
+          <button
+            className="px-4 py-2 rounded bg-primary/10 hover:bg-primary/20 text-primary"
+            onClick={loadMore}
+          >
+            {t('Load more')}
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+function BookmarkedNote({ eventId, isPrivate }: { eventId: string; isPrivate: boolean }) {
+  const { t } = useTranslation()
+  const { event, isFetching } = useFetchEvent(eventId)
+
+  if (isFetching) {
+    return (
+      <div className="p-4 border rounded animate-pulse">
+        <div className="h-4 bg-muted rounded w-1/3 mb-2"></div>
+        <div className="h-4 bg-muted rounded w-2/3"></div>
+      </div>
+    )
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: use `NoteCardLoadingSkeleton`
created_at: 2025-04-16T12:28:07Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
+    )
+  }
+
+  if (bookmarkItems.length === 0) {
+    return (
+      <div className="p-4 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} isPrivate={item.private} />
+      ))}
+
+      {visibleBookmarks.length < bookmarkItems.length && (
+        <div className="flex justify-center py-4">
+          <button
+            className="px-4 py-2 rounded bg-primary/10 hover:bg-primary/20 text-primary"
+            onClick={loadMore}
+          >
+            {t('Load more')}
+          </button>
+        </div>
+      )}
+    </div>
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: It needs to load automatically, refer to `NoteList` for reference.
created_at: 2025-04-16T12:28:58Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
+    )
+  }
+
+  if (bookmarkItems.length === 0) {
+    return (
+      <div className="p-4 text-center text-muted-foreground">
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: mt-2 text-sm text-center text-muted-foreground
created_at: 2025-04-16T12:31:21Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
+        relayHint: tag.length > 2 ? tag[2] : undefined
+      }))
+      .reverse()
+  }, [bookmarks])
+
+  useEffect(() => {
+    setVisibleBookmarks(bookmarkItems.slice(0, 10))
+    setLoading(false)
+  }, [bookmarkItems])
+
+  const loadMore = () => {
+    if (visibleBookmarks.length < bookmarkItems.length) {
+      setVisibleBookmarks((prev) => [
+        ...prev,
+        ...bookmarkItems.slice(prev.length, prev.length + 10)
+      ])
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader className="animate-spin size-8" />
+      </div>
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: use `NoteCardLoadingSkeleton`
created_at: 2025-04-16T12:31:41Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,94 @@
+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'
+
+export default function BookmarkButton({
+  eventId,
+  eventPubkey,
path: src/components/BookmarkButton/index.tsx
user: login: CodyTseng
body: Pass in the entire event to keep the code consistent.
created_at: 2025-04-16T12:33:16Z
author_association: OWNER
diff_hunk: @@ -40,6 +40,7 @@ export const GROUP_METADATA_EVENT_KIND = 39000
 export const ExtendedKind = {
   PICTURE: 20,
   FAVORITE_RELAYS: 10012,
+  BOOKMARK: 10003,
path: src/constants.ts
user: login: CodyTseng
body: Use `kinds.BookmarkList` instead.
created_at: 2025-04-16T12:37:15Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,85 @@
+import { createBookmarkDraftEvent } from '@/lib/draft-event'
+import { createContext, useContext, useMemo } from 'react'
+import { useNostr } from './NostrProvider'
+
+type TBookmarksContext = {
+  bookmarks: string[][]
+  addBookmark: (
+    eventId: string,
+    eventPubkey: string,
+    relayHint?: string,
+    type?: 'public' | 'private'
+  ) => 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,
+    type: 'public' | 'private' = 'public'
+  ) => {
+    if (!accountPubkey) return
+
path: src/providers/BookmarksProvider.tsx
user: login: CodyTseng
body: ```
import client from '@/services/client.service'

const relayHint = client.getEventHint()
```
created_at: 2025-04-16T12:46:22Z
author_association: OWNER
diff_hunk: @@ -170,6 +171,47 @@ 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)
+
+      const bookmarkEvent = await indexedDb.getReplaceableEvent(
+        options.pubkey,
+        ExtendedKind.BOOKMARK
+      )
+      if (!bookmarkEvent || !bookmarkEvent.tags.length) {
+        setRelayUrls([])
+        setFilter({})
+        return setIsReady(true)
+      }
+
+      const eventIds = bookmarkEvent.tags
+        .filter((tag: string[]) => tag[0] === 'e')
+        .map((tag: string[]) => tag[1])
+
+      if (eventIds.length === 0) {
+        setRelayUrls([])
+        setFilter({})
+        return setIsReady(true)
+      }
+
+      const relayHints = bookmarkEvent.tags
+        .filter((tag: string[]) => tag[0] === 'e' && tag.length > 2 && tag[2])
+        .map((tag: string[]) => tag[2])
+        .filter((url: string) => url && isWebsocketUrl(url))
+
+      setRelayUrls([...new Set([...relayHints, ...BIG_RELAY_URLS])])
+      setFilter({
+        ids: eventIds
+      })
+      return setIsReady(true)
path: src/providers/FeedProvider.tsx
user: login: CodyTseng
body: No need to fetch bookmarks here.
created_at: 2025-04-16T12:52:42Z
author_association: OWNER
diff_hunk: @@ -360,6 +366,10 @@ class IndexedDbService {
       {
         name: StoreNames.FOLLOW_LIST_EVENTS,
         expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
+      },
+      {
+        name: StoreNames.BOOKMARK_LIST_EVENTS,
+        expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
path: src/services/indexed-db.service.ts
user: login: CodyTseng
body: No need to clean up, as it will be updated on every startup.
created_at: 2025-04-16T12:55:01Z
author_association: OWNER
diff_hunk: @@ -360,6 +366,10 @@ class IndexedDbService {
       {
         name: StoreNames.FOLLOW_LIST_EVENTS,
         expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
+      },
+      {
+        name: StoreNames.BOOKMARK_LIST_EVENTS,
+        expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
path: src/services/indexed-db.service.ts
user: login: saithsab877
body: > No need to clean up, as it will be updated on every startup.

@CodyTseng You ask no need clean
created_at: 2025-04-16T13:14:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -360,6 +366,10 @@ class IndexedDbService {
       {
         name: StoreNames.FOLLOW_LIST_EVENTS,
         expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
+      },
+      {
+        name: StoreNames.BOOKMARK_LIST_EVENTS,
+        expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
path: src/services/indexed-db.service.ts
user: login: CodyTseng
body: Yes. There's no need to regularly clear the bookmarklist events in IndexedDB.
created_at: 2025-04-16T13:49:51Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
+        private: tag.length > 4 && tag[4] === 'private',
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: The code related to `private` in `BookmarksProvider` is also unnecessary.
created_at: 2025-04-17T13:45:09Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,109 @@
+import { useFetchEvent } from '@/hooks'
+import { useBookmarks } from '@/providers/BookmarksProvider'
+import { Loader, LockIcon } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Note from '../Note'
+
+export default function BookmarksList() {
+  const { t } = useTranslation()
+  const { bookmarks } = useBookmarks()
+  const [visibleBookmarks, setVisibleBookmarks] = useState<
+    { eventId: string; private: boolean; relayHint?: string }[]
+  >([])
+  const [loading, setLoading] = useState(true)
+
+  const bookmarkItems = useMemo(() => {
+    return bookmarks
+      .filter((tag) => tag[0] === 'e')
+      .map((tag) => ({
+        eventId: tag[1],
path: src/components/BookmarksList/index.tsx
user: login: CodyTseng
body: Unmodified
created_at: 2025-04-17T13:45:50Z
author_association: OWNER
diff_hunk: @@ -360,6 +366,10 @@ class IndexedDbService {
       {
         name: StoreNames.FOLLOW_LIST_EVENTS,
         expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
+      },
+      {
+        name: StoreNames.BOOKMARK_LIST_EVENTS,
+        expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
path: src/services/indexed-db.service.ts
user: login: CodyTseng
body: Unmodified
created_at: 2025-04-17T13:50:18Z
author_association: OWNER

PR comments:
user: login: saithsab877
created_at: 2025-04-16T06:35:19Z
body: @bitcoinuser @CodyTseng Please review this PR
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T06:45:01Z
body: @CodyTseng @bitcoinuser @vcavallo https://github.com/CodyTseng/jumble/issues/90#issuecomment-2697787756

Please review this PR. I’ve also added a demo for your reference.
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T07:10:38Z
body: Very well done! However, when there are a large number of saved notes, directly using the `NoteList` component doesn't work well, many notes won't be retrieved, and it will attempt to connect to too many relays at once. But that’s totally fine, it’s already beyond my expectations. I can improve it myself later.
```
const eventIds = bookmarkEvent.tags
  .filter((tag: string[]) => tag[0] === 'e')
  .map((tag: string[]) => tag[1])

if (eventIds.length === 0) {
  setRelayUrls([])
  setFilter({})
  return setIsReady(true)
}

const relayHints = bookmarkEvent.tags
  .filter((tag: string[]) => tag[0] === 'e' && tag.length > 2 && tag[2])
  .map((tag: string[]) => tag[2])
  .filter((url: string) => url && isWebsocketUrl(url))

setRelayUrls([...new Set([...relayHints, ...BIG_RELAY_URLS])])
setFilter({
  ids: eventIds
})
```
author_association: OWNER
user: login: CodyTseng
created_at: 2025-04-16T07:14:51Z
body: I’d like the `BookmarkButton` to have a consistent style with the other buttons like `LikeButton`, `ZapButton`, and `ReplyButton`.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T07:53:37Z
body: > I’d like the `BookmarkButton` to have a consistent style with the other buttons like `LikeButton`, `ZapButton`, and `ReplyButton`.

@CodyTseng Thanks Man!
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T07:59:24Z
body: > Very well done! However, when there are a large number of saved notes, directly using the `NoteList` component doesn't work well, many notes won't be retrieved, and it will attempt to connect to too many relays at once. But that’s totally fine, it’s already beyond my expectations. I can improve it myself later.

@CodyTseng Nice Man!
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T08:03:59Z
body: > Very well done! However, when there are a large number of saved notes, directly using the `NoteList` component doesn't work well, many notes won't be retrieved, and it will attempt to connect to too many relays at once. But that’s totally fine, it’s already beyond my expectations. I can improve it myself later.
> 
> ```
> const eventIds = bookmarkEvent.tags
>   .filter((tag: string[]) => tag[0] === 'e')
>   .map((tag: string[]) => tag[1])
> 
> if (eventIds.length === 0) {
>   setRelayUrls([])
>   setFilter({})
>   return setIsReady(true)
> }
> 
> const relayHints = bookmarkEvent.tags
>   .filter((tag: string[]) => tag[0] === 'e' && tag.length > 2 && tag[2])
>   .map((tag: string[]) => tag[2])
>   .filter((url: string) => url && isWebsocketUrl(url))
> 
> setRelayUrls([...new Set([...relayHints, ...BIG_RELAY_URLS])])
> setFilter({
>   ids: eventIds
> })
> ```

@CodyTseng Fixed Please review again PR 
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T08:07:53Z
body: I noticed that you weren’t the one who originally agreed to take on this issue, and I think that’s not ideal because it might render someone else’s efforts pointless. So I’d prefer to wait for @DeMonkeyCoder to submit his PR before making any decisions.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T08:10:54Z
body: > I noticed that you weren’t the one who originally agreed to take on this issue, and I think that’s not ideal because it might render someone else’s efforts pointless. So I’d prefer to wait for @DeMonkeyCoder to submit his PR before making any decisions.

@CodyTseng Thanks for the clarification — that makes sense. However, just to clarify, what if DeMonkeyCoder ends up submitting a PR that closely mirrors or directly copies the code I've already written? How should situations like that be handled?
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T08:16:44Z
body: @bitcoinuser @CodyTseng I spent almost a full day understanding and solving this issue. I’d really appreciate it if you could review and consider merging this PR. Thank you!
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T08:55:25Z
body: > > Very well done! However, when there are a large number of saved notes, directly using the `NoteList` component doesn't work well, many notes won't be retrieved, and it will attempt to connect to too many relays at once. But that’s totally fine, it’s already beyond my expectations. I can improve it myself later.
> > ```
> > const eventIds = bookmarkEvent.tags
> >   .filter((tag: string[]) => tag[0] === 'e')
> >   .map((tag: string[]) => tag[1])
> > 
> > if (eventIds.length === 0) {
> >   setRelayUrls([])
> >   setFilter({})
> >   return setIsReady(true)
> > }
> > 
> > const relayHints = bookmarkEvent.tags
> >   .filter((tag: string[]) => tag[0] === 'e' && tag.length > 2 && tag[2])
> >   .map((tag: string[]) => tag[2])
> >   .filter((url: string) => url && isWebsocketUrl(url))
> > 
> > setRelayUrls([...new Set([...relayHints, ...BIG_RELAY_URLS])])
> > setFilter({
> >   ids: eventIds
> > })
> > ```
> 
> @CodyTseng Fixed Please review again PR

Limiting the number of displayed items is not advisable. It is not feasible to directly use the NoteList component to display bookmarks, a new component needs to be created for displaying them. 

Each note has to be fetched individually using its e-tag info. That’s the only way to reliably retrieve the notes. You can use the `useFetchEvent` hook for this.

If @DeMonkeyCoder is willing, let's leave this part to him for optimization.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T09:00:41Z
body: > > > Very well done! However, when there are a large number of saved notes, directly using the `NoteList` component doesn't work well, many notes won't be retrieved, and it will attempt to connect to too many relays at once. But that’s totally fine, it’s already beyond my expectations. I can improve it myself later.
> > > ```
> > > const eventIds = bookmarkEvent.tags
> > >   .filter((tag: string[]) => tag[0] === 'e')
> > >   .map((tag: string[]) => tag[1])
> > > 
> > > if (eventIds.length === 0) {
> > >   setRelayUrls([])
> > >   setFilter({})
> > >   return setIsReady(true)
> > > }
> > > 
> > > const relayHints = bookmarkEvent.tags
> > >   .filter((tag: string[]) => tag[0] === 'e' && tag.length > 2 && tag[2])
> > >   .map((tag: string[]) => tag[2])
> > >   .filter((url: string) => url && isWebsocketUrl(url))
> > > 
> > > setRelayUrls([...new Set([...relayHints, ...BIG_RELAY_URLS])])
> > > setFilter({
> > >   ids: eventIds
> > > })
> > > ```
> > 
> > 
> > @CodyTseng Fixed Please review again PR
> 
> Limiting the number of displayed items is not advisable. It is not feasible to directly use the NoteList component to display bookmarks, a new component needs to be created for displaying them.
> 
> Each note has to be fetched individually using its e-tag info. That’s the only way to reliably retrieve the notes. You can use the `useFetchEvent` hook for this.
> 
> If @DeMonkeyCoder is willing, let's leave this part to him for optimization.

@CodyTseng Cool, sounds good. @DeMonkeyCoder, feel free to take it from here and handle the optimization part when you're ready!
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T11:33:52Z
body: @CodyTseng @bitcoinuser Please review this PR I fixed :

https://www.loom.com/share/8664c2d101df4d0d9bf4cc30a7dceee0
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T11:35:31Z
body: @bitcoinuser Please review demo
author_association: CONTRIBUTOR
user: login: saithsab877
created_at: 2025-04-16T13:06:24Z
body: @CodyTseng Sure Please merge this PR
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T13:11:34Z
body: > @CodyTseng Sure Please merge this PR

Sorry, I clicked the wrong thing. You need to fix the issue I mentioned first.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-16T13:17:13Z
body: > > @CodyTseng Sure Please merge this PR
> 
> Sorry, I clicked the wrong thing. You need to fix the issue I mentioned first.

@CodyTseng Could you again mentioned link
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-16T13:51:20Z
body: > > > @CodyTseng Sure Please merge this PR
> > 
> > 
> > Sorry, I clicked the wrong thing. You need to fix the issue I mentioned first.
> 
> @CodyTseng Could you again mentioned link

Did my comments not show up on your GitHub?
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-17T10:19:15Z
body: @CodyTseng Fixed Please review this PR.
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-17T13:53:18Z
body: > Fixed Please review this PR.

There are three missed issues that haven’t been addressed yet, I’ve marked the rest as resolved. Once those three are fixed, I’ll go ahead and merge the PR. I’ll take care of the remaining minor details myself after merging. Thanks for your hard work!
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-17T15:47:00Z
body: @CodyTseng Fixed Please review again
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-18T03:55:05Z
body: Why not use the `generateEventIdFromETag` generated `eventId`? 🤣 Otherwise, what's the point of it? The `eventId` generated by `generateEventIdFromETag` includes information like `pubkey` and `relayHint`, which helps `useFetchEvent` find the event more effectively. Relying on just `event.id` makes it much harder to find the event.
author_association: OWNER
user: login: saithsab877
created_at: 2025-04-18T06:54:38Z
body: @CodyTseng Fixed Please review this PR.
author_association: CONTRIBUTOR
user: login: CodyTseng
created_at: 2025-04-18T07:12:01Z
body: I'll merge this PR when I have some time later. Also, just curious, was the code written by AI? 😂 The last few commits give me the feeling that the model lost some context.
author_association: OWNER