21 changed files with 642 additions and 63 deletions
@ -0,0 +1,38 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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