diff --git a/apps/mobile/src/assets/icons.tsx b/apps/mobile/src/assets/icons.tsx index 552d6bdc..98ae5fd4 100644 --- a/apps/mobile/src/assets/icons.tsx +++ b/apps/mobile/src/assets/icons.tsx @@ -414,17 +414,26 @@ export const LikeIcon: React.FC = (props) => { }; export const BookmarkIcon: React.FC = (props) => ( + + + +); + +export const BookmarkFillIcon: React.FC = (props) => ( ); + export const LikeFillIcon: React.FC = (props) => { return ( diff --git a/apps/mobile/src/modules/Post/index.tsx b/apps/mobile/src/modules/Post/index.tsx index f267492e..6bffbc81 100644 --- a/apps/mobile/src/modules/Post/index.tsx +++ b/apps/mobile/src/modules/Post/index.tsx @@ -4,7 +4,7 @@ import {useQueryClient} from '@tanstack/react-query'; import {useProfile, useReact, useReactions, useReplyNotes, useRepost, useBookmark} from 'afk_nostr_sdk'; // import { useAuth } from '../../store/auth'; import {useAuth} from 'afk_nostr_sdk'; -import {useMemo, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, Image, Pressable, View} from 'react-native'; import Animated, { Easing, @@ -28,10 +28,11 @@ export type PostProps = { asComment?: boolean; event?: NDKEvent; repostedEventProps?:string; - isRepost?:boolean + isRepost?:boolean; + isBookmarked?:boolean; }; -export const Post: React.FC = ({asComment, event, repostedEventProps, isRepost}) => { +export const Post: React.FC = ({asComment, event, repostedEventProps, isRepost, isBookmarked = false}) => { const repostedEvent = repostedEventProps ?? undefined; const {theme} = useTheme(); @@ -50,7 +51,7 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, const react = useReact(); const queryClient = useQueryClient(); const repostMutation = useRepost({ event }); - const { bookmarkNote } = useBookmark(publicKey); + const { bookmarkNote, removeBookmark } = useBookmark(publicKey); const [menuOpen, setMenuOpen] = useState(false); @@ -137,11 +138,16 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, const handleBookmark = async () => { if (!event) return; try { - await bookmarkNote({ event }); - showToast({title: 'Post bookmarked successfully', type: 'success'}); + if (isBookmarked) { + await removeBookmark({ eventId: event.id }); + showToast({ title: 'Post removed from bookmarks', type: 'success' }); + } else { + await bookmarkNote({ event }); + showToast({ title: 'Post bookmarked successfully', type: 'success' }); + } } catch (error) { console.error('Bookmark error:', error); - showToast({title: 'Failed to bookmark', type: 'error'}); + showToast({ title: 'Failed to bookmark', type: 'error' }); } }; @@ -296,7 +302,10 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, style={{marginHorizontal: 3}} onPress={handleBookmark} > - + diff --git a/apps/mobile/src/modules/PostCard/index.tsx b/apps/mobile/src/modules/PostCard/index.tsx index 8d3f8c85..225f8f06 100644 --- a/apps/mobile/src/modules/PostCard/index.tsx +++ b/apps/mobile/src/modules/PostCard/index.tsx @@ -8,9 +8,10 @@ import { useState } from 'react'; export type PostCardProps = { event?: NDKEvent; isRepostProps?:boolean; + isBookmarked?:boolean; }; -export const PostCard: React.FC = ({ event, isRepostProps }) => { +export const PostCard: React.FC = ({ event, isRepostProps, isBookmarked }) => { const styles = useStyles(stylesheet); let repostedEvent = undefined; @@ -21,7 +22,7 @@ export const PostCard: React.FC = ({ event, isRepostProps }) => { } return ( - + ); }; diff --git a/apps/mobile/src/screens/Profile/index.tsx b/apps/mobile/src/screens/Profile/index.tsx index ddefbcc0..5ca38e6d 100644 --- a/apps/mobile/src/screens/Profile/index.tsx +++ b/apps/mobile/src/screens/Profile/index.tsx @@ -1,4 +1,4 @@ -import { useReposts, useRootNotes, useSearch, useSearchNotes } from 'afk_nostr_sdk'; +import { useBookmark, useReposts, useRootNotes, useSearch } from 'afk_nostr_sdk'; import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, View } from 'react-native'; import { useStyles } from '../../hooks'; @@ -8,20 +8,41 @@ import { ProfileInfo } from './Info'; import stylesheet from './styles'; import { useMemo, useState } from 'react'; import { NDKKind } from '@nostr-dev-kit/ndk'; -import { Button, Text } from '../../components'; +import { Text } from '../../components'; export const Profile: React.FC = ({ route }) => { const { publicKey } = route.params ?? {}; const styles = useStyles(stylesheet); - const [ndkKind, setNdkKind] = useState(NDKKind.Text) + const [ndkKinds, setNdkKind] = useState([NDKKind.Text]); const kindFilter = useMemo(() => { - return ndkKind - }, [ndkKind]) + return ndkKinds + }, [ndkKinds]) const notesSearch = useRootNotes({ authors: [publicKey] }); - const search = useSearch({ authors: [publicKey], kind: kindFilter }); + const search = useSearch({ authors: [publicKey], kinds: kindFilter }); const reposts = useReposts({ authors: [publicKey] }); + const { bookmarksWithNotes } = useBookmark(publicKey); + + // Extract all bookmarked note IDs + const bookmarkedNoteIds = useMemo(() => { + if (!bookmarksWithNotes) return new Set(); + + const ids = new Set(); + bookmarksWithNotes.forEach(bookmark => { + bookmark.notes.forEach(note => { + ids.add(note?.id || ''); + }); + }); + return ids; + }, [bookmarksWithNotes]); + + // Function to check if a note is bookmarked + const isBookmarked = (noteId: string) => bookmarkedNoteIds.has(noteId); + + const getData = ndkKinds.includes(NDKKind.BookmarkList) || ndkKinds.includes(NDKKind.BookmarkSet) + ? bookmarksWithNotes?.map(bookmark => bookmark.notes).flat() || [] + : search.data?.pages.flat(); return ( @@ -33,40 +54,38 @@ export const Profile: React.FC = ({ route }) => { - setNdkKind(NDKKind.Text)}> + setNdkKind([NDKKind.Text])} + style={[styles.option, ndkKinds.includes(NDKKind.Text) && styles.selected]} + > Notes - setNdkKind(NDKKind.Repost)}> + setNdkKind([NDKKind.Repost])} + style={[styles.option, ndkKinds.includes(NDKKind.Repost) && styles.selected]} + > Repost + setNdkKind([NDKKind.BookmarkList, NDKKind.BookmarkSet])} + style={[styles.option, ndkKinds.includes(NDKKind.BookmarkList) && styles.selected]} + > + Bookmarks + } - data={search.data?.pages.flat()} + data={getData} keyExtractor={(item) => item.id} renderItem={({ item }) => { - if (ndkKind == NDKKind.Repost) { - const itemReposted = JSON.parse(item?.content) + if (ndkKinds.includes(NDKKind.Repost)) { + const itemReposted = JSON.parse(item?.content); return } - return + return }} refreshControl={ search.refetch()} /> diff --git a/apps/mobile/src/screens/Profile/styles.ts b/apps/mobile/src/screens/Profile/styles.ts index e62901bd..085f902c 100644 --- a/apps/mobile/src/screens/Profile/styles.ts +++ b/apps/mobile/src/screens/Profile/styles.ts @@ -5,4 +5,33 @@ export default ThemedStyleSheet((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, + optionsContentContainer: { + paddingVertical: 5, + paddingHorizontal:5, + flexDirection: 'row', + rowGap: 3, + gap: 3, + columnGap: 15, + }, + optionsContainer: { + paddingHorizontal:5, + paddingVertical: 5, + flexDirection: 'row', + rowGap: 3, + gap: 3, + columnGap: 3 + }, + option: { + paddingVertical: 10, + paddingHorizontal: 20, + borderWidth: 1, + borderStyle: 'solid', + borderColor: theme.colors.primary, + borderRadius: 20, + color: theme.colors.textLight, + }, + selected: { + backgroundColor: theme.colors.primary, + color: theme.colors.text + } })); diff --git a/packages/afk_nostr_sdk/src/hooks/useBookmark.ts b/packages/afk_nostr_sdk/src/hooks/useBookmark.ts index 36a37a36..4e4263ad 100644 --- a/packages/afk_nostr_sdk/src/hooks/useBookmark.ts +++ b/packages/afk_nostr_sdk/src/hooks/useBookmark.ts @@ -16,77 +16,157 @@ export const useBookmark = (userPublicKey: string) => { const { ndk } = useNostrContext(); const queryClient = useQueryClient(); + const fetchBookmarks = async () => { + if (!ndk.signer) { + throw new Error('No signer available'); + } + const filter = { kinds: [NDKKind.BookmarkList, NDKKind.BookmarkSet], authors: [userPublicKey] }; + const events = await ndk.fetchEvents(filter); + + const eventsArray = Array.from(events); + + // Fetch full content for each bookmarked event + const fullEvents = await Promise.all(eventsArray.map(async (event) => { + const fullEvent = await ndk.fetchEvent(event.id); + return fullEvent; + })); + + return fullEvents; + }; + + const bookmarks = useQuery({ + queryKey: ['bookmarks', userPublicKey], + queryFn: fetchBookmarks, + enabled: !!userPublicKey, + }); + + const extractNoteIds = (bookmarks: NDKEvent[]) => { + const noteIds: Set = new Set(); + + bookmarks.forEach(bookmark => { + bookmark.tags.forEach(tag => { + if (tag[0] === 'e') { + noteIds.add(tag[1]); // Collect note IDs + } + }); + }); + + return Array.from(noteIds); + }; + + const fetchNotesByIds = async (noteIds: string[]) => { + if (!ndk.signer) { + throw new Error('No signer available'); + } + + const filter = { ids: noteIds }; + const events = await ndk.fetchEvents(filter); + return Array.from(events); + }; + + const fetchBookmarksWithNotes = async () => { + const bookmarks = await fetchBookmarks(); + const noteIds = extractNoteIds(bookmarks); + const notes = await fetchNotesByIds(noteIds); + + // Create a mapping of note ID to note event + const noteMap = new Map(notes.map(note => [note.id, note])); + + // Combine bookmarks with note data + const bookmarksWithNotes = bookmarks.map(bookmark => { + const bookmarkedNotes = bookmark.tags + .filter(tag => tag[0] === 'e') + .map(tag => noteMap.get(tag[1])); + + return { + bookmarkEvent: bookmark, + notes: bookmarkedNotes, + }; + }); + + return bookmarksWithNotes; + }; + + const bookmarksWithNotesQuery = useQuery({ + queryKey: ['bookmarksWithNotes', userPublicKey], + queryFn: fetchBookmarksWithNotes, + enabled: !!userPublicKey, + }); + + const bookmarkNote = useMutation({ - mutationKey: ["bookmark", ndk], + mutationKey: ['bookmark', ndk], mutationFn: async ({ event, category }: BookmarkParams) => { if (!event) { throw new Error('No event provided for bookmark'); } - const bookmarkEvent = new NDKEvent(ndk); + let bookmarks = await fetchBookmarks(); + let bookmarkEvent = bookmarks.find((e) => e.kind === (category ? NDKKind.BookmarkSet : NDKKind.BookmarkList)); + + console.log('bookmarkEvent', bookmarkEvent); + + if (!bookmarkEvent) { + bookmarkEvent = new NDKEvent(ndk); + bookmarkEvent.kind = category ? NDKKind.BookmarkSet : NDKKind.BookmarkList; + bookmarkEvent.content = ''; + bookmarkEvent.tags = []; + } + + // Resetting the id and created_at to avoid conflicts + bookmarkEvent.id = undefined as any; + bookmarkEvent.created_at = undefined; + // If there's a specific category, add it if (category) { - bookmarkEvent.kind = NDKKind.BookmarkSet; - bookmarkEvent.tags = [ - ['d', category], - ['e', event.id, event.relay?.url || ''], - ['p', event.pubkey], - ]; - } else { - bookmarkEvent.kind = 10003; - bookmarkEvent.tags = [ - ['e', event.id, event.relay?.url || ''], - ['p', event.pubkey], - ]; + const existingTagIndex = bookmarkEvent.tags.findIndex(tag => tag[0] === 'd' && tag[1] === category); + if (existingTagIndex === -1) { + bookmarkEvent.tags.push(['d', category]); + } + } + + const existingEventIndex = bookmarkEvent.tags.findIndex(tag => tag[0] === 'e' && tag[1] === event.id); + if (existingEventIndex === -1) { + bookmarkEvent.tags.push(['e', event.id, event.relay?.url || '']); + bookmarkEvent.tags.push(['p', event.pubkey]); } - await bookmarkEvent.publish(); - return bookmarkEvent; + await bookmarkEvent.sign(); + return bookmarkEvent.publish(); + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + queryClient.invalidateQueries({ queryKey: ['bookmarks', userPublicKey] }); }, onError: (error) => { console.error('Failed to bookmark note:', error); }, }); - const getBookmarks = useQuery({ - queryKey: ['bookmarks', userPublicKey], - queryFn: async () => { - if (!ndk.signer) { - throw new Error('No signer available'); - } - const filter = { kinds: [10003, 30003], authors: [userPublicKey] }; - const events = await ndk.fetchEvents(filter); - return Array.from(events); - }, - }); - const removeBookmark = useMutation({ - mutationKey: ["bookmark", ndk], + mutationKey: ['bookmarks', ndk], mutationFn: async ({ eventId, category }: RemoveBookmarkParams) => { - const existingBookmarks = getBookmarks.data; + let bookmarks = await fetchBookmarks(); + let bookmarkEvent = bookmarks.find((e) => e.kind === (category ? NDKKind.BookmarkSet : NDKKind.BookmarkList)); - if (!existingBookmarks) { - throw new Error('No existing bookmarks found'); + if (!bookmarkEvent) { + throw new Error('Bookmark not found'); } - const bookmarkEvent = existingBookmarks.find((event) => { - const isMatchingCategory = category - ? event.tags.some(tag => tag[0] === 'd' && tag[1] === category) - : true; + // Resetting the id and created_at to avoid conflicts + bookmarkEvent.id = undefined as any; + bookmarkEvent.created_at = undefined; - return isMatchingCategory && event.tags.some(tag => tag[0] === 'e' && tag[1] === eventId); - }); + if (category) { + bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'd' && tag[1] === category)); + } - if (bookmarkEvent) { - bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'e' && tag[1] === eventId)); - if (bookmarkEvent.tags.length > 0) { - await bookmarkEvent.publish(); - } - } else { - throw new Error('Bookmark not found'); + // Remove the specific event + bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'e' && tag[1] === eventId)); + + if (bookmarkEvent.tags.length > 0) { + await bookmarkEvent.sign(); + await bookmarkEvent.publish(); } }, onSuccess: () => { @@ -100,7 +180,8 @@ export const useBookmark = (userPublicKey: string) => { return { bookmarkNote: bookmarkNote.mutateAsync, removeBookmark: removeBookmark.mutateAsync, - getBookmarks: getBookmarks.data, - isFetchingBookmarks: getBookmarks.isFetching, + bookmarks: bookmarks.data, + isFetchingBookmarks: bookmarks.isFetching, + bookmarksWithNotes: bookmarksWithNotesQuery.data }; };