You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

211 lines
8.2 KiB

import JsonViewDialog from '@/components/JsonViewDialog'
import PersonalListBech32List from '@/components/PersonalListBech32List'
import { RefreshButton } from '@/components/RefreshButton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { bookmarkBech32IdsFromListEvent } from '@/lib/personal-list-refs'
import { createBookmarkDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { getLatestEvent } from '@/lib/event'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { normalizeUrl } from '@/lib/url'
import { PROFILE_RELAY_URLS } from '@/constants'
import { queryService } from '@/services/client.service'
import dayjs from 'dayjs'
import { Code, Eraser, MoreVertical } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
const BookmarkListPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, bookmarkListEvent, relayList, publish, updateBookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent])
const refreshFromRelays = useCallback(async () => {
if (!pubkey) return
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
let latest =
(await fetchLatestReplaceableListEvent(pubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null
if (!latest) {
const urls = Array.from(
new Set(
[
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...(relayList?.write ?? []).map((u) => normalizeUrl(u) || u)
].filter(Boolean)
)
).slice(0, 12)
if (urls.length) {
try {
const events = await queryService.fetchEvents(urls, {
kinds: [kinds.BookmarkList],
authors: [pubkey],
limit: 5
})
latest = getLatestEvent(events) ?? null
} catch {
/* ignore */
}
}
}
if (latest) await updateBookmarkListEvent(latest)
}, [pubkey, favoriteRelays, blockedRelays, relayList?.write, updateBookmarkListEvent])
const openJson = useCallback(() => {
setJsonPayload({
bookmarkListEvent: bookmarkListEvent ?? null,
derivedBech32Ids: bech32Ids,
note: 'Bookmarks are `e` / `a` tags on your kind 10003 (NIP-51) bookmark list replaceable event.'
})
setJsonOpen(true)
}, [bookmarkListEvent, bech32Ids])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void refreshFromRelays()
})
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
if (dayjs().unix() === bookmarkListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createBookmarkDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateBookmarkListEvent(published)
await refreshFromRelays()
toast.success(t('List cleaned'))
} catch (e) {
toast.error(t('Failed to clean list') + ': ' + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaning(false)
setCleanConfirmOpen(false)
}
}, [pubkey, cleaning, bookmarkListEvent?.created_at, favoriteRelays, blockedRelays, publish, updateBookmarkListEvent, refreshFromRelays, t])
if (!profile || !pubkey) {
return <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={
hideTitlebar
? undefined
: t("username's bookmarks", { username: profile.username, defaultValue: `${profile.username}'s bookmarks` })
}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void refreshFromRelays()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div key={bookmarkListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No entries in bookmark list')}</p>
) : (
<PersonalListBech32List bech32Ids={bech32Ids} listMode="bookmark" />
)}
</div>
</SecondaryPageLayout>
)
}
)
BookmarkListPage.displayName = 'BookmarkListPage'
export default BookmarkListPage