6 changed files with 310 additions and 310 deletions
@ -1,78 +1,78 @@ |
|||||||
import { useToast } from '@/hooks' |
import { useToast } from '@/hooks' |
||||||
import { useBookmarks } from '@/providers/BookmarksProvider' |
import { useBookmarks } from '@/providers/BookmarksProvider' |
||||||
import { useNostr } from '@/providers/NostrProvider' |
import { useNostr } from '@/providers/NostrProvider' |
||||||
import { BookmarkIcon, Loader } from 'lucide-react' |
import { BookmarkIcon, Loader } from 'lucide-react' |
||||||
import { useMemo, useState } from 'react' |
import { useMemo, useState } from 'react' |
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
import { Event } from 'nostr-tools' |
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
export default function BookmarkButton({ event }: { event: Event }) { |
export default function BookmarkButton({ event }: { event: Event }) { |
||||||
const { t } = useTranslation() |
const { t } = useTranslation() |
||||||
const { toast } = useToast() |
const { toast } = useToast() |
||||||
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr() |
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr() |
||||||
const { addBookmark, removeBookmark } = useBookmarks() |
const { addBookmark, removeBookmark } = useBookmarks() |
||||||
const [updating, setUpdating] = useState(false) |
const [updating, setUpdating] = useState(false) |
||||||
const isBookmarked = useMemo( |
const isBookmarked = useMemo( |
||||||
() => bookmarkListEvent?.tags.some((tag) => tag[0] === 'e' && tag[1] === event.id), |
() => bookmarkListEvent?.tags.some((tag) => tag[0] === 'e' && tag[1] === event.id), |
||||||
[bookmarkListEvent, event] |
[bookmarkListEvent, event] |
||||||
) |
) |
||||||
|
|
||||||
if (!accountPubkey) return null |
if (!accountPubkey) return null |
||||||
|
|
||||||
const handleBookmark = async (e: React.MouseEvent) => { |
const handleBookmark = async (e: React.MouseEvent) => { |
||||||
e.stopPropagation() |
e.stopPropagation() |
||||||
checkLogin(async () => { |
checkLogin(async () => { |
||||||
if (isBookmarked) return |
if (isBookmarked) return |
||||||
|
|
||||||
setUpdating(true) |
setUpdating(true) |
||||||
try { |
try { |
||||||
await addBookmark(event) |
await addBookmark(event) |
||||||
} catch (error) { |
} catch (error) { |
||||||
toast({ |
toast({ |
||||||
title: t('Bookmark failed'), |
title: t('Bookmark failed'), |
||||||
description: (error as Error).message, |
description: (error as Error).message, |
||||||
variant: 'destructive' |
variant: 'destructive' |
||||||
}) |
}) |
||||||
} finally { |
} finally { |
||||||
setUpdating(false) |
setUpdating(false) |
||||||
} |
} |
||||||
}) |
}) |
||||||
} |
} |
||||||
|
|
||||||
const handleRemoveBookmark = async (e: React.MouseEvent) => { |
const handleRemoveBookmark = async (e: React.MouseEvent) => { |
||||||
e.stopPropagation() |
e.stopPropagation() |
||||||
checkLogin(async () => { |
checkLogin(async () => { |
||||||
if (!isBookmarked) return |
if (!isBookmarked) return |
||||||
|
|
||||||
setUpdating(true) |
setUpdating(true) |
||||||
try { |
try { |
||||||
await removeBookmark(event) |
await removeBookmark(event) |
||||||
} catch (error) { |
} catch (error) { |
||||||
toast({ |
toast({ |
||||||
title: t('Remove bookmark failed'), |
title: t('Remove bookmark failed'), |
||||||
description: (error as Error).message, |
description: (error as Error).message, |
||||||
variant: 'destructive' |
variant: 'destructive' |
||||||
}) |
}) |
||||||
} finally { |
} finally { |
||||||
setUpdating(false) |
setUpdating(false) |
||||||
} |
} |
||||||
}) |
}) |
||||||
} |
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<button |
<button |
||||||
className={`flex items-center gap-1 ${ |
className={`flex items-center gap-1 ${ |
||||||
isBookmarked ? 'text-rose-400' : 'text-muted-foreground' |
isBookmarked ? 'text-rose-400' : 'text-muted-foreground' |
||||||
} enabled:hover:text-rose-400 px-3 h-full`}
|
} enabled:hover:text-rose-400 px-3 h-full`}
|
||||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark} |
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark} |
||||||
disabled={updating} |
disabled={updating} |
||||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} |
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} |
||||||
> |
> |
||||||
{updating ? ( |
{updating ? ( |
||||||
<Loader className="animate-spin" /> |
<Loader className="animate-spin" /> |
||||||
) : ( |
) : ( |
||||||
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} /> |
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} /> |
||||||
)} |
)} |
||||||
</button> |
</button> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -1,97 +1,97 @@ |
|||||||
import { useFetchEvent } from '@/hooks' |
import { useFetchEvent } from '@/hooks' |
||||||
import { generateEventIdFromETag } from '@/lib/tag' |
import { generateEventIdFromETag } from '@/lib/tag' |
||||||
import { useNostr } from '@/providers/NostrProvider' |
import { useNostr } from '@/providers/NostrProvider' |
||||||
import { kinds } from 'nostr-tools' |
import { kinds } from 'nostr-tools' |
||||||
import { useEffect, useMemo, useRef, useState } from 'react' |
import { useEffect, useMemo, useRef, useState } from 'react' |
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' |
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' |
||||||
|
|
||||||
const SHOW_COUNT = 10 |
const SHOW_COUNT = 10 |
||||||
|
|
||||||
export default function BookmarkList() { |
export default function BookmarkList() { |
||||||
const { t } = useTranslation() |
const { t } = useTranslation() |
||||||
const { bookmarkListEvent } = useNostr() |
const { bookmarkListEvent } = useNostr() |
||||||
const eventIds = useMemo(() => { |
const eventIds = useMemo(() => { |
||||||
if (!bookmarkListEvent) return [] |
if (!bookmarkListEvent) return [] |
||||||
|
|
||||||
return ( |
return ( |
||||||
bookmarkListEvent.tags |
bookmarkListEvent.tags |
||||||
.map((tag) => (tag[0] === 'e' ? generateEventIdFromETag(tag) : undefined)) |
.map((tag) => (tag[0] === 'e' ? generateEventIdFromETag(tag) : undefined)) |
||||||
.filter(Boolean) as `nevent1${string}`[] |
.filter(Boolean) as `nevent1${string}`[] |
||||||
).reverse() |
).reverse() |
||||||
}, [bookmarkListEvent]) |
}, [bookmarkListEvent]) |
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
const [showCount, setShowCount] = useState(SHOW_COUNT) |
||||||
const bottomRef = useRef<HTMLDivElement | null>(null) |
const bottomRef = useRef<HTMLDivElement | null>(null) |
||||||
|
|
||||||
useEffect(() => { |
useEffect(() => { |
||||||
const options = { |
const options = { |
||||||
root: null, |
root: null, |
||||||
rootMargin: '10px', |
rootMargin: '10px', |
||||||
threshold: 0.1 |
threshold: 0.1 |
||||||
} |
} |
||||||
|
|
||||||
const loadMore = () => { |
const loadMore = () => { |
||||||
if (showCount < eventIds.length) { |
if (showCount < eventIds.length) { |
||||||
setShowCount((prev) => prev + SHOW_COUNT) |
setShowCount((prev) => prev + SHOW_COUNT) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => { |
const observerInstance = new IntersectionObserver((entries) => { |
||||||
if (entries[0].isIntersecting) { |
if (entries[0].isIntersecting) { |
||||||
loadMore() |
loadMore() |
||||||
} |
} |
||||||
}, options) |
}, options) |
||||||
|
|
||||||
const currentBottomRef = bottomRef.current |
const currentBottomRef = bottomRef.current |
||||||
|
|
||||||
if (currentBottomRef) { |
if (currentBottomRef) { |
||||||
observerInstance.observe(currentBottomRef) |
observerInstance.observe(currentBottomRef) |
||||||
} |
} |
||||||
|
|
||||||
return () => { |
return () => { |
||||||
if (observerInstance && currentBottomRef) { |
if (observerInstance && currentBottomRef) { |
||||||
observerInstance.unobserve(currentBottomRef) |
observerInstance.unobserve(currentBottomRef) |
||||||
} |
} |
||||||
} |
} |
||||||
}, [showCount, eventIds]) |
}, [showCount, eventIds]) |
||||||
|
|
||||||
if (eventIds.length === 0) { |
if (eventIds.length === 0) { |
||||||
return ( |
return ( |
||||||
<div className="mt-2 text-sm text-center text-muted-foreground"> |
<div className="mt-2 text-sm text-center text-muted-foreground"> |
||||||
{t('no bookmarks found')} |
{t('no bookmarks found')} |
||||||
</div> |
</div> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div> |
<div> |
||||||
{eventIds.slice(0, showCount).map((eventId) => ( |
{eventIds.slice(0, showCount).map((eventId) => ( |
||||||
<BookmarkedNote key={eventId} eventId={eventId} /> |
<BookmarkedNote key={eventId} eventId={eventId} /> |
||||||
))} |
))} |
||||||
|
|
||||||
{showCount < eventIds.length ? ( |
{showCount < eventIds.length ? ( |
||||||
<div ref={bottomRef}> |
<div ref={bottomRef}> |
||||||
<NoteCardLoadingSkeleton isPictures={false} /> |
<NoteCardLoadingSkeleton isPictures={false} /> |
||||||
</div> |
</div> |
||||||
) : ( |
) : ( |
||||||
<div className="text-center text-sm text-muted-foreground mt-2"> |
<div className="text-center text-sm text-muted-foreground mt-2"> |
||||||
{t('no more bookmarks')} |
{t('no more bookmarks')} |
||||||
</div> |
</div> |
||||||
)} |
)} |
||||||
</div> |
</div> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|
||||||
function BookmarkedNote({ eventId }: { eventId: string }) { |
function BookmarkedNote({ eventId }: { eventId: string }) { |
||||||
const { event, isFetching } = useFetchEvent(eventId) |
const { event, isFetching } = useFetchEvent(eventId) |
||||||
|
|
||||||
if (isFetching) { |
if (isFetching) { |
||||||
return <NoteCardLoadingSkeleton isPictures={false} /> |
return <NoteCardLoadingSkeleton isPictures={false} /> |
||||||
} |
} |
||||||
|
|
||||||
if (!event || event.kind !== kinds.ShortTextNote) { |
if (!event || event.kind !== kinds.ShortTextNote) { |
||||||
return null |
return null |
||||||
} |
} |
||||||
|
|
||||||
return <NoteCard event={event} className="w-full" /> |
return <NoteCard event={event} className="w-full" /> |
||||||
} |
} |
||||||
|
|||||||
@ -1,68 +1,68 @@ |
|||||||
import { Button } from '@/components/ui/button' |
import { Button } from '@/components/ui/button' |
||||||
import { SimpleUserAvatar } from '@/components/UserAvatar' |
import { SimpleUserAvatar } from '@/components/UserAvatar' |
||||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
import { Event } from 'nostr-tools' |
import { Event } from 'nostr-tools' |
||||||
import { useMemo } from 'react' |
import { useMemo } from 'react' |
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
export default function NewNotesButton({ |
export default function NewNotesButton({ |
||||||
newEvents = [], |
newEvents = [], |
||||||
onClick |
onClick |
||||||
}: { |
}: { |
||||||
newEvents?: Event[] |
newEvents?: Event[] |
||||||
onClick?: () => void |
onClick?: () => void |
||||||
}) { |
}) { |
||||||
const { t } = useTranslation() |
const { t } = useTranslation() |
||||||
const { isSmallScreen } = useScreenSize() |
const { isSmallScreen } = useScreenSize() |
||||||
const pubkeys = useMemo(() => { |
const pubkeys = useMemo(() => { |
||||||
const arr: string[] = [] |
const arr: string[] = [] |
||||||
for (const event of newEvents) { |
for (const event of newEvents) { |
||||||
if (!arr.includes(event.pubkey)) { |
if (!arr.includes(event.pubkey)) { |
||||||
arr.push(event.pubkey) |
arr.push(event.pubkey) |
||||||
} |
} |
||||||
if (arr.length >= 3) break |
if (arr.length >= 3) break |
||||||
} |
} |
||||||
return arr |
return arr |
||||||
}, [newEvents]) |
}, [newEvents]) |
||||||
|
|
||||||
return ( |
return ( |
||||||
<> |
<> |
||||||
{newEvents.length > 0 && ( |
{newEvents.length > 0 && ( |
||||||
<div |
<div |
||||||
className={cn( |
className={cn( |
||||||
'w-full flex justify-center z-40 pointer-events-none', |
'w-full flex justify-center z-40 pointer-events-none', |
||||||
isSmallScreen ? 'fixed' : 'absolute bottom-4' |
isSmallScreen ? 'fixed' : 'absolute bottom-4' |
||||||
)} |
)} |
||||||
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined} |
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined} |
||||||
> |
> |
||||||
<Button |
<Button |
||||||
onClick={onClick} |
onClick={onClick} |
||||||
className="group rounded-full h-fit pl-2 pr-3 hover:bg-primary-hover pointer-events-auto" |
className="group rounded-full h-fit pl-2 pr-3 hover:bg-primary-hover pointer-events-auto" |
||||||
> |
> |
||||||
{pubkeys.length > 0 && ( |
{pubkeys.length > 0 && ( |
||||||
<div className="flex items-center"> |
<div className="flex items-center"> |
||||||
{pubkeys.map((pubkey, index) => ( |
{pubkeys.map((pubkey, index) => ( |
||||||
<div |
<div |
||||||
key={pubkey} |
key={pubkey} |
||||||
className="relative -mr-2.5 last:mr-0" |
className="relative -mr-2.5 last:mr-0" |
||||||
style={{ zIndex: 3 - index }} |
style={{ zIndex: 3 - index }} |
||||||
> |
> |
||||||
<SimpleUserAvatar |
<SimpleUserAvatar |
||||||
userId={pubkey} |
userId={pubkey} |
||||||
size="small" |
size="small" |
||||||
className="border-primary border-2 group-hover:border-primary-hover" |
className="border-primary border-2 group-hover:border-primary-hover" |
||||||
/> |
/> |
||||||
</div> |
</div> |
||||||
))} |
))} |
||||||
</div> |
</div> |
||||||
)} |
)} |
||||||
<div className="text-md font-medium"> |
<div className="text-md font-medium"> |
||||||
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })} |
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })} |
||||||
</div> |
</div> |
||||||
</Button> |
</Button> |
||||||
</div> |
</div> |
||||||
)} |
)} |
||||||
</> |
</> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -1,65 +1,65 @@ |
|||||||
import { createBookmarkDraftEvent } from '@/lib/draft-event' |
import { createBookmarkDraftEvent } from '@/lib/draft-event' |
||||||
import client from '@/services/client.service' |
import client from '@/services/client.service' |
||||||
import { createContext, useContext } from 'react' |
import { createContext, useContext } from 'react' |
||||||
import { useNostr } from './NostrProvider' |
import { useNostr } from './NostrProvider' |
||||||
import { Event } from 'nostr-tools' |
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
type TBookmarksContext = { |
type TBookmarksContext = { |
||||||
addBookmark: (event: Event) => Promise<void> |
addBookmark: (event: Event) => Promise<void> |
||||||
removeBookmark: (event: Event) => Promise<void> |
removeBookmark: (event: Event) => Promise<void> |
||||||
} |
} |
||||||
|
|
||||||
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined) |
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined) |
||||||
|
|
||||||
export const useBookmarks = () => { |
export const useBookmarks = () => { |
||||||
const context = useContext(BookmarksContext) |
const context = useContext(BookmarksContext) |
||||||
if (!context) { |
if (!context) { |
||||||
throw new Error('useBookmarks must be used within a BookmarksProvider') |
throw new Error('useBookmarks must be used within a BookmarksProvider') |
||||||
} |
} |
||||||
return context |
return context |
||||||
} |
} |
||||||
|
|
||||||
export function BookmarksProvider({ children }: { children: React.ReactNode }) { |
export function BookmarksProvider({ children }: { children: React.ReactNode }) { |
||||||
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() |
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() |
||||||
|
|
||||||
const addBookmark = async (event: Event) => { |
const addBookmark = async (event: Event) => { |
||||||
if (!accountPubkey) return |
if (!accountPubkey) return |
||||||
|
|
||||||
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey) |
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey) |
||||||
const currentTags = bookmarkListEvent?.tags || [] |
const currentTags = bookmarkListEvent?.tags || [] |
||||||
|
|
||||||
if (currentTags.some((tag) => tag[0] === 'e' && tag[1] === event.id)) return |
if (currentTags.some((tag) => tag[0] === 'e' && tag[1] === event.id)) return |
||||||
|
|
||||||
const newBookmarkDraftEvent = createBookmarkDraftEvent( |
const newBookmarkDraftEvent = createBookmarkDraftEvent( |
||||||
[...currentTags, ['e', event.id, client.getEventHint(event.id), '', event.pubkey]], |
[...currentTags, ['e', event.id, client.getEventHint(event.id), '', event.pubkey]], |
||||||
bookmarkListEvent?.content |
bookmarkListEvent?.content |
||||||
) |
) |
||||||
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
||||||
await updateBookmarkListEvent(newBookmarkEvent) |
await updateBookmarkListEvent(newBookmarkEvent) |
||||||
} |
} |
||||||
|
|
||||||
const removeBookmark = async (event: Event) => { |
const removeBookmark = async (event: Event) => { |
||||||
if (!accountPubkey) return |
if (!accountPubkey) return |
||||||
|
|
||||||
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey) |
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey) |
||||||
if (!bookmarkListEvent) return |
if (!bookmarkListEvent) return |
||||||
|
|
||||||
const newTags = bookmarkListEvent.tags.filter((tag) => !(tag[0] === 'e' && tag[1] === event.id)) |
const newTags = bookmarkListEvent.tags.filter((tag) => !(tag[0] === 'e' && tag[1] === event.id)) |
||||||
if (newTags.length === bookmarkListEvent.tags.length) return |
if (newTags.length === bookmarkListEvent.tags.length) return |
||||||
|
|
||||||
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) |
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) |
||||||
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
const newBookmarkEvent = await publish(newBookmarkDraftEvent) |
||||||
await updateBookmarkListEvent(newBookmarkEvent) |
await updateBookmarkListEvent(newBookmarkEvent) |
||||||
} |
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<BookmarksContext.Provider |
<BookmarksContext.Provider |
||||||
value={{ |
value={{ |
||||||
addBookmark, |
addBookmark, |
||||||
removeBookmark |
removeBookmark |
||||||
}} |
}} |
||||||
> |
> |
||||||
{children} |
{children} |
||||||
</BookmarksContext.Provider> |
</BookmarksContext.Provider> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue