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