21 changed files with 642 additions and 63 deletions
@ -0,0 +1,38 @@ |
|||||||
|
import PersonalListNoteRefRow from '@/components/PersonalListNoteRefRow' |
||||||
|
import { useEffect, useRef, useState } from 'react' |
||||||
|
|
||||||
|
const PAGE = 10 |
||||||
|
|
||||||
|
/** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */ |
||||||
|
export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: string[] }) { |
||||||
|
const [visible, setVisible] = useState<string[]>([]) |
||||||
|
const bottomRef = useRef<HTMLDivElement>(null) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setVisible(bech32Ids.slice(0, PAGE)) |
||||||
|
}, [bech32Ids]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const el = bottomRef.current |
||||||
|
if (!el) return |
||||||
|
const obs = new IntersectionObserver( |
||||||
|
(entries) => { |
||||||
|
if (entries[0]?.isIntersecting && bech32Ids.length > visible.length) { |
||||||
|
setVisible((prev) => [...prev, ...bech32Ids.slice(prev.length, prev.length + PAGE)]) |
||||||
|
} |
||||||
|
}, |
||||||
|
{ root: null, rootMargin: '10px', threshold: 1 } |
||||||
|
) |
||||||
|
obs.observe(el) |
||||||
|
return () => obs.disconnect() |
||||||
|
}, [visible, bech32Ids]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-0 divide-y divide-border/60"> |
||||||
|
{visible.map((id) => ( |
||||||
|
<PersonalListNoteRefRow key={id} bech32Id={id} /> |
||||||
|
))} |
||||||
|
{bech32Ids.length > visible.length ? <div ref={bottomRef} className="h-4" /> : null} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { useFetchEvent } from '@/hooks' |
||||||
|
import { toNote } from '@/lib/link' |
||||||
|
import { useSmartNoteNavigation } from '@/PageManager' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import UserAvatar from '@/components/UserAvatar' |
||||||
|
import Username from '@/components/Username' |
||||||
|
import { ChevronRight } from 'lucide-react' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
/** |
||||||
|
* One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists). |
||||||
|
*/ |
||||||
|
export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { event, isFetching } = useFetchEvent(bech32Id) |
||||||
|
const { navigateToNote } = useSmartNoteNavigation() |
||||||
|
const preview = useMemo(() => { |
||||||
|
const c = event?.content?.trim() |
||||||
|
if (!c) return '' |
||||||
|
return c.replace(/\s+/g, ' ').slice(0, 140) |
||||||
|
}, [event?.content]) |
||||||
|
|
||||||
|
const onOpen = () => navigateToNote(toNote(bech32Id)) |
||||||
|
|
||||||
|
if (isFetching) { |
||||||
|
return ( |
||||||
|
<div className="flex items-center gap-2 px-4 py-2"> |
||||||
|
<Skeleton className="size-10 shrink-0 rounded-full" /> |
||||||
|
<div className="min-w-0 flex-1 space-y-2"> |
||||||
|
<Skeleton className="h-4 w-32" /> |
||||||
|
<Skeleton className="h-3 w-full max-w-md" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
className="h-auto min-h-[3.5rem] w-full justify-start gap-2 rounded-none px-4 py-2 font-normal hover:bg-muted/60" |
||||||
|
onClick={onOpen} |
||||||
|
> |
||||||
|
{event ? ( |
||||||
|
<> |
||||||
|
<UserAvatar userId={event.pubkey} className="shrink-0" /> |
||||||
|
<div className="min-w-0 flex-1 text-left"> |
||||||
|
<Username |
||||||
|
userId={event.pubkey} |
||||||
|
className="max-w-full truncate font-semibold" |
||||||
|
skeletonClassName="h-4" |
||||||
|
/> |
||||||
|
<div className="truncate text-sm text-muted-foreground"> |
||||||
|
{preview || t('Event kind label', { kind: event.kind })} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<div className="min-w-0 flex-1 text-left font-mono text-xs text-muted-foreground"> |
||||||
|
{bech32Id.length > 36 ? `${bech32Id.slice(0, 28)}…` : bech32Id} |
||||||
|
<div className="mt-0.5 text-[11px]">{t('Event not loaded')}</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<ChevronRight className="size-4 shrink-0 opacity-50" /> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
function pushBech32FromTag(tag: string[], out: string[]) { |
||||||
|
const [name, v] = tag |
||||||
|
if (name === 'e' && v && /^[0-9a-f]{64}$/i.test(v)) { |
||||||
|
const n = generateBech32IdFromETag(tag) |
||||||
|
if (n) out.push(n) |
||||||
|
} else if (name === 'a' && v?.trim()) { |
||||||
|
const n = generateBech32IdFromATag(tag) |
||||||
|
if (n) out.push(n) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function dedupePreserveOrder(ids: string[]): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const next: string[] = [] |
||||||
|
for (const id of ids) { |
||||||
|
if (seen.has(id)) continue |
||||||
|
seen.add(id) |
||||||
|
next.push(id) |
||||||
|
} |
||||||
|
return next |
||||||
|
} |
||||||
|
|
||||||
|
/** NIP-51 kind 10003 bookmark list: `e` / `a` → nevent/naddr, newest-first (matches home bookmarks feed). */ |
||||||
|
export function bookmarkBech32IdsFromListEvent(ev: Event | null): string[] { |
||||||
|
if (!ev?.tags?.length) return [] |
||||||
|
const raw: string[] = [] |
||||||
|
for (const t of ev.tags) pushBech32FromTag(t, raw) |
||||||
|
return dedupePreserveOrder(raw).reverse() |
||||||
|
} |
||||||
|
|
||||||
|
/** Kind 10001 pin list: `e` reversed then `a`, same ordering as profile pins. */ |
||||||
|
export function pinBech32IdsFromListEvent(ev: Event | null): string[] { |
||||||
|
if (!ev?.tags?.length) return [] |
||||||
|
const tags = ev.tags |
||||||
|
const eTags = tags.filter((t) => t[0] === 'e') |
||||||
|
const aTags = tags.filter((t) => t[0] === 'a') |
||||||
|
const raw: string[] = [] |
||||||
|
for (const t of [...eTags].reverse()) pushBech32FromTag(t, raw) |
||||||
|
for (const t of aTags) pushBech32FromTag(t, raw) |
||||||
|
return dedupePreserveOrder(raw) |
||||||
|
} |
||||||
@ -0,0 +1,143 @@ |
|||||||
|
import JsonViewDialog from '@/components/JsonViewDialog' |
||||||
|
import PersonalListBech32List from '@/components/PersonalListBech32List' |
||||||
|
import { RefreshButton } from '@/components/RefreshButton' |
||||||
|
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 { 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_FETCH_RELAY_URLS } from '@/constants' |
||||||
|
import { queryService } from '@/services/client.service' |
||||||
|
import { Code, MoreVertical } from 'lucide-react' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
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, updateBookmarkListEvent } = useNostr() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
const [jsonOpen, setJsonOpen] = useState(false) |
||||||
|
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||||
|
|
||||||
|
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_FETCH_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]) |
||||||
|
|
||||||
|
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> |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||||
|
<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} /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
BookmarkListPage.displayName = 'BookmarkListPage' |
||||||
|
export default BookmarkListPage |
||||||
@ -0,0 +1,151 @@ |
|||||||
|
import JsonViewDialog from '@/components/JsonViewDialog' |
||||||
|
import PersonalListBech32List from '@/components/PersonalListBech32List' |
||||||
|
import { RefreshButton } from '@/components/RefreshButton' |
||||||
|
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 { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||||
|
import { pinBech32IdsFromListEvent } from '@/lib/personal-list-refs' |
||||||
|
import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import { Code, MoreVertical } from 'lucide-react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import NotFoundPage from '../NotFoundPage' |
||||||
|
|
||||||
|
const PinListPage = forwardRef( |
||||||
|
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||||
|
const { profile, pubkey } = useNostr() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
const [pinListEvent, setPinListEvent] = useState<Event | null>(null) |
||||||
|
const [jsonOpen, setJsonOpen] = useState(false) |
||||||
|
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||||
|
|
||||||
|
const loadPins = useCallback(async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setPinListEvent(null) |
||||||
|
return |
||||||
|
} |
||||||
|
let cached: Event | null | undefined |
||||||
|
try { |
||||||
|
cached = (await indexedDb.getReplaceableEvent(pubkey, 10001)) ?? undefined |
||||||
|
} catch { |
||||||
|
cached = undefined |
||||||
|
} |
||||||
|
const relays = await buildAccountListRelayUrlsForMerge({ |
||||||
|
accountPubkey: pubkey, |
||||||
|
favoriteRelays: favoriteRelays ?? [], |
||||||
|
blockedRelays |
||||||
|
}) |
||||||
|
const fromNet = await fetchNewestPinListForPubkey(pubkey, relays) |
||||||
|
const best = |
||||||
|
!cached && fromNet |
||||||
|
? fromNet |
||||||
|
: cached && !fromNet |
||||||
|
? cached |
||||||
|
: cached && fromNet |
||||||
|
? fromNet.created_at >= cached.created_at |
||||||
|
? fromNet |
||||||
|
: cached |
||||||
|
: null |
||||||
|
setPinListEvent(best ?? null) |
||||||
|
if (best) { |
||||||
|
try { |
||||||
|
await indexedDb.putReplaceableEvent(best) |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
} |
||||||
|
}, [pubkey, favoriteRelays, blockedRelays]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
void loadPins() |
||||||
|
}, [loadPins]) |
||||||
|
|
||||||
|
const bech32Ids = useMemo(() => pinBech32IdsFromListEvent(pinListEvent), [pinListEvent]) |
||||||
|
|
||||||
|
const openJson = useCallback(() => { |
||||||
|
setJsonPayload({ |
||||||
|
pinListEvent: pinListEvent ?? null, |
||||||
|
derivedBech32Ids: bech32Ids, |
||||||
|
note: 'Pins are `e` / `a` tags on your kind 10001 replaceable pin list event.' |
||||||
|
}) |
||||||
|
setJsonOpen(true) |
||||||
|
}, [pinListEvent, bech32Ids]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!hideTitlebar) { |
||||||
|
registerPrimaryPanelRefresh(null) |
||||||
|
return |
||||||
|
} |
||||||
|
registerPrimaryPanelRefresh(() => { |
||||||
|
void loadPins() |
||||||
|
}) |
||||||
|
return () => registerPrimaryPanelRefresh(null) |
||||||
|
}, [hideTitlebar, registerPrimaryPanelRefresh, loadPins]) |
||||||
|
|
||||||
|
if (!profile || !pubkey) { |
||||||
|
return <NotFoundPage /> |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<SecondaryPageLayout |
||||||
|
ref={ref} |
||||||
|
index={index} |
||||||
|
title={ |
||||||
|
hideTitlebar |
||||||
|
? undefined |
||||||
|
: t("username's pinned notes", { |
||||||
|
username: profile.username, |
||||||
|
defaultValue: `${profile.username}'s pinned notes` |
||||||
|
}) |
||||||
|
} |
||||||
|
hideBackButton={hideTitlebar} |
||||||
|
controls={ |
||||||
|
hideTitlebar ? undefined : ( |
||||||
|
<div className="flex items-center gap-0"> |
||||||
|
<RefreshButton onClick={() => void loadPins()} /> |
||||||
|
<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> |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||||
|
<div key={pinListEvent?.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 pinned notes in list')}</p> |
||||||
|
) : ( |
||||||
|
<PersonalListBech32List bech32Ids={bech32Ids} /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
PinListPage.displayName = 'PinListPage' |
||||||
|
export default PinListPage |
||||||
Loading…
Reference in new issue