21 changed files with 1585 additions and 4 deletions
@ -0,0 +1,80 @@ |
|||||||
|
import PublicationCard from '@/components/Note/PublicationCard' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import type { LibraryPublicationEntry } from '@/lib/library-publication-index' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Highlighter, MessageSquare, Tag } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
if (!entry.hasLabel && !entry.hasComment && !entry.hasHighlight) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex flex-wrap gap-2 px-1 pb-2"> |
||||||
|
{entry.hasLabel && ( |
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"> |
||||||
|
<Tag className="size-3" aria-hidden /> |
||||||
|
{t('Library badge label')} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{entry.hasComment && ( |
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"> |
||||||
|
<MessageSquare className="size-3" aria-hidden /> |
||||||
|
{t('Library badge comment')} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{entry.hasHighlight && ( |
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"> |
||||||
|
<Highlighter className="size-3" aria-hidden /> |
||||||
|
{t('Library badge highlight')} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default function LibraryPublicationGrid({ |
||||||
|
entries, |
||||||
|
loading, |
||||||
|
emptyMessage |
||||||
|
}: { |
||||||
|
entries: LibraryPublicationEntry[] |
||||||
|
loading?: boolean |
||||||
|
emptyMessage?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> |
||||||
|
{Array.from({ length: 6 }).map((_, i) => ( |
||||||
|
<Skeleton key={i} className="h-48 w-full rounded-lg" /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (entries.length === 0) { |
||||||
|
return ( |
||||||
|
<div className="rounded-lg border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground"> |
||||||
|
{emptyMessage ?? t('Library empty')} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> |
||||||
|
{entries.map((entry) => ( |
||||||
|
<div |
||||||
|
key={entry.event.id} |
||||||
|
className={cn( |
||||||
|
'flex min-w-0 flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden' |
||||||
|
)} |
||||||
|
> |
||||||
|
<PublicationCard event={entry.event} className="border-0 shadow-none rounded-none" /> |
||||||
|
<EngagementBadges entry={entry} /> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import { Search } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function LibrarySearchBar({ |
||||||
|
searchQuery, |
||||||
|
onSearchQueryChange, |
||||||
|
showOnlyMine, |
||||||
|
onShowOnlyMineChange, |
||||||
|
disabled |
||||||
|
}: { |
||||||
|
searchQuery: string |
||||||
|
onSearchQueryChange: (value: string) => void |
||||||
|
showOnlyMine: boolean |
||||||
|
onShowOnlyMineChange: (value: boolean) => void |
||||||
|
disabled?: boolean |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-3"> |
||||||
|
<div className="relative"> |
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> |
||||||
|
<Input |
||||||
|
type="search" |
||||||
|
value={searchQuery} |
||||||
|
onChange={(e) => onSearchQueryChange(e.target.value)} |
||||||
|
placeholder={t('Library search placeholder')} |
||||||
|
className="pl-9" |
||||||
|
disabled={disabled} |
||||||
|
aria-label={t('Library search placeholder')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<Switch |
||||||
|
id="library-show-mine" |
||||||
|
checked={showOnlyMine} |
||||||
|
onCheckedChange={onShowOnlyMineChange} |
||||||
|
disabled={disabled} |
||||||
|
/> |
||||||
|
<Label htmlFor="library-show-mine" className="text-sm text-muted-foreground cursor-pointer"> |
||||||
|
{t('Library show only my publications')} |
||||||
|
</Label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { usePrimaryPage } from '@/contexts/primary-page-context' |
||||||
|
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { BookOpen } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function LibraryButton() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigate, current, display } = usePrimaryPage() |
||||||
|
const { primaryViewType } = usePrimaryNoteView() |
||||||
|
|
||||||
|
return ( |
||||||
|
<SidebarItem |
||||||
|
title={t('Library')} |
||||||
|
onClick={() => navigate('library')} |
||||||
|
active={current === 'library' && display && primaryViewType === null} |
||||||
|
> |
||||||
|
<BookOpen strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export function LibraryTitlebarButton({ className }: { className?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigate, current, display } = usePrimaryPage() |
||||||
|
const { primaryViewType } = usePrimaryNoteView() |
||||||
|
const active = display && current === 'library' && primaryViewType === null |
||||||
|
|
||||||
|
return ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="titlebar-icon" |
||||||
|
title={t('Library')} |
||||||
|
aria-label={t('Library')} |
||||||
|
className={cn('shrink-0', active && 'bg-accent/50', className)} |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
navigate('library') |
||||||
|
}} |
||||||
|
> |
||||||
|
<BookOpen className="size-5" strokeWidth={2.5} /> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
import { |
||||||
|
clearLibraryPublicationIndexCache, |
||||||
|
filterLibraryPublicationsBySearch, |
||||||
|
filterLibraryPublicationsByUser, |
||||||
|
buildLibraryRelayUrls, |
||||||
|
loadLibraryPublicationIndex, |
||||||
|
type LibraryPublicationEntry |
||||||
|
} from '@/lib/library-publication-index' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300 |
||||||
|
|
||||||
|
export function useLibraryPublications(isActive: boolean) { |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) |
||||||
|
const [searchQuery, setSearchQuery] = useState('') |
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('') |
||||||
|
const [showOnlyMine, setShowOnlyMine] = useState(false) |
||||||
|
const [loading, setLoading] = useState(false) |
||||||
|
const [error, setError] = useState<string | null>(null) |
||||||
|
const [allIndexCount, setAllIndexCount] = useState(0) |
||||||
|
const [topLevelCount, setTopLevelCount] = useState(0) |
||||||
|
const loadGenRef = useRef(0) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const t = window.setTimeout(() => setDebouncedSearch(searchQuery), SEARCH_DEBOUNCE_MS) |
||||||
|
return () => window.clearTimeout(t) |
||||||
|
}, [searchQuery]) |
||||||
|
|
||||||
|
const load = useCallback( |
||||||
|
async (forceRefresh = false) => { |
||||||
|
const gen = ++loadGenRef.current |
||||||
|
setLoading(true) |
||||||
|
setError(null) |
||||||
|
try { |
||||||
|
const relays = await buildLibraryRelayUrls(pubkey || undefined) |
||||||
|
const result = await loadLibraryPublicationIndex(relays, { forceRefresh }) |
||||||
|
if (gen !== loadGenRef.current) return |
||||||
|
setEntries(result.engaged) |
||||||
|
setAllIndexCount(result.allIndexCount) |
||||||
|
setTopLevelCount(result.topLevelCount) |
||||||
|
} catch (e) { |
||||||
|
if (gen !== loadGenRef.current) return |
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load library') |
||||||
|
} finally { |
||||||
|
if (gen === loadGenRef.current) setLoading(false) |
||||||
|
} |
||||||
|
}, |
||||||
|
[pubkey] |
||||||
|
) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!isActive) return |
||||||
|
void load(false) |
||||||
|
}, [isActive, load]) |
||||||
|
|
||||||
|
const refresh = useCallback(() => { |
||||||
|
clearLibraryPublicationIndexCache() |
||||||
|
void load(true) |
||||||
|
}, [load]) |
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => { |
||||||
|
let list = entries |
||||||
|
if (showOnlyMine) { |
||||||
|
list = filterLibraryPublicationsByUser(list, pubkey) |
||||||
|
} |
||||||
|
if (debouncedSearch.trim()) { |
||||||
|
list = filterLibraryPublicationsBySearch(list, debouncedSearch) |
||||||
|
} |
||||||
|
return list |
||||||
|
}, [entries, showOnlyMine, pubkey, debouncedSearch]) |
||||||
|
|
||||||
|
return { |
||||||
|
entries: filteredEntries, |
||||||
|
searchQuery, |
||||||
|
setSearchQuery, |
||||||
|
showOnlyMine, |
||||||
|
setShowOnlyMine, |
||||||
|
loading, |
||||||
|
error, |
||||||
|
allIndexCount, |
||||||
|
topLevelCount, |
||||||
|
refresh |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
import { describe, expect, it, vi } from 'vitest' |
||||||
|
import { Lazy, LazyStatus } from '@/lib/lazy' |
||||||
|
|
||||||
|
describe('Lazy', () => { |
||||||
|
it('memoizes successful resolution', async () => { |
||||||
|
const resolver = vi.fn(async () => 'ok') |
||||||
|
const lazy = new Lazy(resolver) |
||||||
|
|
||||||
|
expect(await lazy.value()).toBe('ok') |
||||||
|
expect(await lazy.value()).toBe('ok') |
||||||
|
expect(resolver).toHaveBeenCalledTimes(1) |
||||||
|
expect(lazy.status).toBe(LazyStatus.Resolved) |
||||||
|
}) |
||||||
|
|
||||||
|
it('memoizes error state and does not retry the resolver', async () => { |
||||||
|
const resolver = vi.fn(async () => { |
||||||
|
throw new Error('fail') |
||||||
|
}) |
||||||
|
const lazy = new Lazy(resolver) |
||||||
|
|
||||||
|
expect(await lazy.value()).toBeNull() |
||||||
|
expect(await lazy.value()).toBeNull() |
||||||
|
expect(resolver).toHaveBeenCalledTimes(1) |
||||||
|
expect(lazy.status).toBe(LazyStatus.Error) |
||||||
|
}) |
||||||
|
|
||||||
|
it('dedupes concurrent value() calls while pending', async () => { |
||||||
|
let resolve!: (value: number) => void |
||||||
|
const resolver = vi.fn( |
||||||
|
() => |
||||||
|
new Promise<number>((r) => { |
||||||
|
resolve = r |
||||||
|
}) |
||||||
|
) |
||||||
|
const lazy = new Lazy(resolver) |
||||||
|
|
||||||
|
const first = lazy.value() |
||||||
|
const second = lazy.value() |
||||||
|
expect(resolver).toHaveBeenCalledTimes(1) |
||||||
|
|
||||||
|
resolve(42) |
||||||
|
expect(await first).toBe(42) |
||||||
|
expect(await second).toBe(42) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
export enum LazyStatus { |
||||||
|
Pending, |
||||||
|
Resolved, |
||||||
|
Error |
||||||
|
} |
||||||
|
|
||||||
|
export class Lazy<T> { |
||||||
|
#value: T | null = null |
||||||
|
#resolver: () => Promise<T> |
||||||
|
#pendingPromise: Promise<T | null> | null = null |
||||||
|
|
||||||
|
status: LazyStatus |
||||||
|
|
||||||
|
constructor(resolver: () => Promise<T>) { |
||||||
|
this.#resolver = resolver |
||||||
|
this.status = LazyStatus.Pending |
||||||
|
} |
||||||
|
|
||||||
|
value(): Promise<T | null> { |
||||||
|
if (this.status === LazyStatus.Resolved) { |
||||||
|
return Promise.resolve(this.#value) |
||||||
|
} |
||||||
|
|
||||||
|
if (this.status === LazyStatus.Error) { |
||||||
|
return Promise.resolve(null) |
||||||
|
} |
||||||
|
|
||||||
|
if (this.#pendingPromise) { |
||||||
|
return this.#pendingPromise |
||||||
|
} |
||||||
|
|
||||||
|
this.#pendingPromise = this.#resolve() |
||||||
|
return this.#pendingPromise |
||||||
|
} |
||||||
|
|
||||||
|
async #resolve(): Promise<T | null> { |
||||||
|
try { |
||||||
|
this.#value = await this.#resolver() |
||||||
|
this.status = LazyStatus.Resolved |
||||||
|
return this.#value |
||||||
|
} catch { |
||||||
|
this.status = LazyStatus.Error |
||||||
|
return null |
||||||
|
} finally { |
||||||
|
this.#pendingPromise = null |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
buildEngagementMapsFromEvents, |
||||||
|
filterEngagedPublications, |
||||||
|
filterLibraryPublicationsBySearch |
||||||
|
} from '@/lib/library-publication-index' |
||||||
|
import { buildIndexByAddress } from '@/lib/publication-index' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
|
||||||
|
const PK = 'a'.repeat(64) |
||||||
|
|
||||||
|
function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
kind: ExtendedKind.PUBLICATION, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 100, |
||||||
|
content: '', |
||||||
|
tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])], |
||||||
|
sig: 'c'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('library-publication-index', () => { |
||||||
|
it('matches engagement on nested 30041 addresses', async () => { |
||||||
|
const leafAddr = `30041:${PK}:chapter-1` |
||||||
|
const childAddr = `30040:${PK}:part-1` |
||||||
|
const root = indexEvent('book', [childAddr]) |
||||||
|
const child = indexEvent('part-1', [leafAddr], '2'.repeat(64)) |
||||||
|
const indexByAddress = buildIndexByAddress([root, child]) |
||||||
|
|
||||||
|
const highlight: Event = { |
||||||
|
id: '3'.repeat(64), |
||||||
|
kind: kinds.Highlights, |
||||||
|
pubkey: 'f'.repeat(64), |
||||||
|
created_at: 50, |
||||||
|
content: 'highlighted text', |
||||||
|
tags: [['a', leafAddr]], |
||||||
|
sig: 'e'.repeat(128) |
||||||
|
} |
||||||
|
|
||||||
|
const engagement = buildEngagementMapsFromEvents([], [], [highlight]) |
||||||
|
const engaged = await filterEngagedPublications([root], indexByAddress, engagement, []) |
||||||
|
|
||||||
|
expect(engaged).toHaveLength(1) |
||||||
|
expect(engaged[0].hasHighlight).toBe(true) |
||||||
|
expect(engaged[0].hasLabel).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('matches labels by root event id', async () => { |
||||||
|
const root = indexEvent('book', [`30041:${PK}:intro`]) |
||||||
|
const indexByAddress = buildIndexByAddress([root]) |
||||||
|
const label: Event = { |
||||||
|
id: '4'.repeat(64), |
||||||
|
kind: ExtendedKind.LABEL, |
||||||
|
pubkey: 'f'.repeat(64), |
||||||
|
created_at: 50, |
||||||
|
content: '', |
||||||
|
tags: [['L', 'license'], ['l', 'MIT', 'license'], ['e', root.id]], |
||||||
|
sig: 'e'.repeat(128) |
||||||
|
} |
||||||
|
const engagement = buildEngagementMapsFromEvents([label], [], []) |
||||||
|
const engaged = await filterEngagedPublications([root], indexByAddress, engagement, []) |
||||||
|
expect(engaged).toHaveLength(1) |
||||||
|
expect(engaged[0].hasLabel).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('filterLibraryPublicationsBySearch matches title', () => { |
||||||
|
const root = indexEvent('book', [`30041:${PK}:intro`]) |
||||||
|
const entries = [ |
||||||
|
{ |
||||||
|
event: root, |
||||||
|
hasLabel: true, |
||||||
|
hasComment: false, |
||||||
|
hasHighlight: false, |
||||||
|
engagementCount: 1 |
||||||
|
} |
||||||
|
] |
||||||
|
expect(filterLibraryPublicationsBySearch(entries, 'title book')).toHaveLength(1) |
||||||
|
expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,326 @@ |
|||||||
|
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' |
||||||
|
import { |
||||||
|
buildIndexByAddress, |
||||||
|
collectReachableAddresses, |
||||||
|
eventTagAddress, |
||||||
|
fetchMissingIndexByAddress, |
||||||
|
filterValidIndexEvents, |
||||||
|
getTopLevelIndexEvents |
||||||
|
} from '@/lib/publication-index' |
||||||
|
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import { queryService } from '@/services/client.service' |
||||||
|
import type { Event, Filter } from 'nostr-tools' |
||||||
|
import { kinds, nip19 } from 'nostr-tools' |
||||||
|
|
||||||
|
const INDEX_FETCH_LIMIT = 500 |
||||||
|
const ENGAGEMENT_FETCH_LIMIT = 500 |
||||||
|
|
||||||
|
export type PublicationEngagementMaps = { |
||||||
|
labelAddresses: Set<string> |
||||||
|
labelEventIds: Set<string> |
||||||
|
commentAddresses: Set<string> |
||||||
|
highlightAddresses: Set<string> |
||||||
|
} |
||||||
|
|
||||||
|
export type LibraryPublicationEntry = { |
||||||
|
event: Event |
||||||
|
hasLabel: boolean |
||||||
|
hasComment: boolean |
||||||
|
hasHighlight: boolean |
||||||
|
engagementCount: number |
||||||
|
} |
||||||
|
|
||||||
|
type LibraryIndexCache = { |
||||||
|
relayKey: string |
||||||
|
indexEvents: Event[] |
||||||
|
indexByAddress: Map<string, Event> |
||||||
|
engagement: PublicationEngagementMaps |
||||||
|
} |
||||||
|
|
||||||
|
let sessionCache: LibraryIndexCache | null = null |
||||||
|
|
||||||
|
function relaySetKey(urls: string[]): string { |
||||||
|
return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|') |
||||||
|
} |
||||||
|
|
||||||
|
export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string[]> { |
||||||
|
const base = LIBRARY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) |
||||||
|
const urls = await buildComprehensiveRelayList({ |
||||||
|
userPubkey, |
||||||
|
includeUserOwnRelays: true, |
||||||
|
includeFastReadRelays: true, |
||||||
|
includeSearchableRelays: true, |
||||||
|
includeFavoriteRelays: true, |
||||||
|
relayHints: base |
||||||
|
}) |
||||||
|
return [...new Set([...base, ...urls])] |
||||||
|
} |
||||||
|
|
||||||
|
function dedupeEventsById(events: Event[]): Event[] { |
||||||
|
const byId = new Map<string, Event>() |
||||||
|
for (const ev of events) { |
||||||
|
const prev = byId.get(ev.id) |
||||||
|
if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev) |
||||||
|
} |
||||||
|
return [...byId.values()] |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { |
||||||
|
if (relayUrls.length === 0) return [] |
||||||
|
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } |
||||||
|
const events = await queryService.fetchEvents(relayUrls, [filter], { |
||||||
|
globalTimeout: 25_000, |
||||||
|
eoseTimeout: 4_000, |
||||||
|
firstRelayResultGraceMs: false |
||||||
|
}) |
||||||
|
return filterValidIndexEvents(dedupeEventsById(events)) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildEngagementMapsFromEvents( |
||||||
|
labels: Event[], |
||||||
|
comments: Event[], |
||||||
|
highlights: Event[] |
||||||
|
): PublicationEngagementMaps { |
||||||
|
const labelAddresses = new Set<string>() |
||||||
|
const labelEventIds = new Set<string>() |
||||||
|
const commentAddresses = new Set<string>() |
||||||
|
const highlightAddresses = new Set<string>() |
||||||
|
|
||||||
|
for (const ev of labels) { |
||||||
|
for (const tag of ev.tags) { |
||||||
|
if (tag[0] === 'a' && tag[1]) labelAddresses.add(tag[1]) |
||||||
|
if (tag[0] === 'e' && tag[1]) labelEventIds.add(tag[1].toLowerCase()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const ev of comments) { |
||||||
|
for (const tag of ev.tags) { |
||||||
|
if (tag[0] === 'A' && tag[1]) commentAddresses.add(tag[1]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const ev of highlights) { |
||||||
|
for (const tag of ev.tags) { |
||||||
|
if (tag[0] === 'a' && tag[1]) highlightAddresses.add(tag[1]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { labelAddresses, labelEventIds, commentAddresses, highlightAddresses } |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchPublicationEngagementMaps( |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<PublicationEngagementMaps> { |
||||||
|
if (relayUrls.length === 0) { |
||||||
|
return { |
||||||
|
labelAddresses: new Set(), |
||||||
|
labelEventIds: new Set(), |
||||||
|
commentAddresses: new Set(), |
||||||
|
highlightAddresses: new Set() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const opts = { |
||||||
|
globalTimeout: 25_000, |
||||||
|
eoseTimeout: 4_000, |
||||||
|
firstRelayResultGraceMs: false as const |
||||||
|
} |
||||||
|
|
||||||
|
const [labels, comments, highlights] = await Promise.all([ |
||||||
|
queryService.fetchEvents( |
||||||
|
relayUrls, |
||||||
|
[{ kinds: [ExtendedKind.LABEL], limit: ENGAGEMENT_FETCH_LIMIT }], |
||||||
|
opts |
||||||
|
), |
||||||
|
queryService.fetchEvents( |
||||||
|
relayUrls, |
||||||
|
[{ kinds: [ExtendedKind.COMMENT], limit: ENGAGEMENT_FETCH_LIMIT }], |
||||||
|
opts |
||||||
|
), |
||||||
|
queryService.fetchEvents( |
||||||
|
relayUrls, |
||||||
|
[{ kinds: [kinds.Highlights], limit: ENGAGEMENT_FETCH_LIMIT }], |
||||||
|
opts |
||||||
|
) |
||||||
|
]) |
||||||
|
|
||||||
|
return buildEngagementMapsFromEvents( |
||||||
|
dedupeEventsById(labels), |
||||||
|
dedupeEventsById(comments), |
||||||
|
dedupeEventsById(highlights) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function addressHasEngagement( |
||||||
|
address: string, |
||||||
|
eventId: string | undefined, |
||||||
|
maps: PublicationEngagementMaps |
||||||
|
): { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean } { |
||||||
|
const hasLabel = |
||||||
|
maps.labelAddresses.has(address) || |
||||||
|
(eventId ? maps.labelEventIds.has(eventId.toLowerCase()) : false) |
||||||
|
const hasComment = maps.commentAddresses.has(address) |
||||||
|
const hasHighlight = maps.highlightAddresses.has(address) |
||||||
|
return { hasLabel, hasComment, hasHighlight } |
||||||
|
} |
||||||
|
|
||||||
|
export async function filterEngagedPublications( |
||||||
|
roots: Event[], |
||||||
|
indexByAddress: Map<string, Event>, |
||||||
|
engagement: PublicationEngagementMaps, |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<LibraryPublicationEntry[]> { |
||||||
|
const fetchMissing = (address: string) => fetchMissingIndexByAddress(address, relayUrls) |
||||||
|
const out: LibraryPublicationEntry[] = [] |
||||||
|
|
||||||
|
for (const root of roots) { |
||||||
|
const reachable = await collectReachableAddresses(root, indexByAddress, fetchMissing) |
||||||
|
const rootAddr = eventTagAddress(root) |
||||||
|
if (rootAddr) reachable.add(rootAddr) |
||||||
|
|
||||||
|
let hasLabel = false |
||||||
|
let hasComment = false |
||||||
|
let hasHighlight = false |
||||||
|
let engagementCount = 0 |
||||||
|
|
||||||
|
for (const addr of reachable) { |
||||||
|
const indexed = indexByAddress.get(addr) |
||||||
|
const flags = addressHasEngagement(addr, indexed?.id, engagement) |
||||||
|
if (flags.hasLabel) hasLabel = true |
||||||
|
if (flags.hasComment) hasComment = true |
||||||
|
if (flags.hasHighlight) hasHighlight = true |
||||||
|
if (flags.hasLabel || flags.hasComment || flags.hasHighlight) engagementCount++ |
||||||
|
} |
||||||
|
|
||||||
|
const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) |
||||||
|
hasLabel = hasLabel || rootFlags.hasLabel |
||||||
|
hasComment = hasComment || rootFlags.hasComment |
||||||
|
hasHighlight = hasHighlight || rootFlags.hasHighlight |
||||||
|
|
||||||
|
if (hasLabel || hasComment || hasHighlight) { |
||||||
|
out.push({ |
||||||
|
event: root, |
||||||
|
hasLabel, |
||||||
|
hasComment, |
||||||
|
hasHighlight, |
||||||
|
engagementCount: Math.max(engagementCount, 1) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { |
||||||
|
return [...entries].sort((a, b) => { |
||||||
|
if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1 |
||||||
|
if (a.engagementCount !== b.engagementCount) return b.engagementCount - a.engagementCount |
||||||
|
return b.event.created_at - a.event.created_at |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeSearchQuery(query: string): string { |
||||||
|
return query.trim().toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function tryNpubFromQuery(query: string): string | null { |
||||||
|
const trimmed = query.trim() |
||||||
|
if (!trimmed) return null |
||||||
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase() |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(trimmed) |
||||||
|
if (decoded.type === 'npub') return decoded.data |
||||||
|
if (decoded.type === 'nprofile') return decoded.data.pubkey |
||||||
|
} catch { |
||||||
|
// not bech32
|
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
export function filterLibraryPublicationsBySearch( |
||||||
|
entries: LibraryPublicationEntry[], |
||||||
|
query: string |
||||||
|
): LibraryPublicationEntry[] { |
||||||
|
const q = normalizeSearchQuery(query) |
||||||
|
if (!q) return entries |
||||||
|
|
||||||
|
const npub = tryNpubFromQuery(q) |
||||||
|
if (npub) { |
||||||
|
return entries.filter(({ event }) => event.pubkey.toLowerCase() === npub) |
||||||
|
} |
||||||
|
|
||||||
|
return entries.filter(({ event }) => { |
||||||
|
const title = event.tags.find((t) => t[0] === 'title')?.[1]?.toLowerCase() ?? '' |
||||||
|
const author = event.tags.find((t) => t[0] === 'author')?.[1]?.toLowerCase() ?? '' |
||||||
|
const nip05 = event.tags.find((t) => t[0] === 'nip05')?.[1]?.toLowerCase() ?? '' |
||||||
|
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() ?? '' |
||||||
|
const pubkey = event.pubkey.toLowerCase() |
||||||
|
return ( |
||||||
|
title.includes(q) || |
||||||
|
author.includes(q) || |
||||||
|
nip05.includes(q) || |
||||||
|
dTag.includes(q) || |
||||||
|
pubkey.includes(q) |
||||||
|
) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function filterLibraryPublicationsByUser( |
||||||
|
entries: LibraryPublicationEntry[], |
||||||
|
userPubkey: string | null | undefined |
||||||
|
): LibraryPublicationEntry[] { |
||||||
|
if (!userPubkey) return entries |
||||||
|
const pk = userPubkey.toLowerCase() |
||||||
|
return entries.filter(({ event }) => { |
||||||
|
if (event.pubkey.toLowerCase() === pk) return true |
||||||
|
return event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export async function loadLibraryPublicationIndex( |
||||||
|
relayUrls: string[], |
||||||
|
options?: { forceRefresh?: boolean } |
||||||
|
): Promise<{ |
||||||
|
engaged: LibraryPublicationEntry[] |
||||||
|
allIndexCount: number |
||||||
|
topLevelCount: number |
||||||
|
}> { |
||||||
|
const key = relaySetKey(relayUrls) |
||||||
|
if (!options?.forceRefresh && sessionCache?.relayKey === key) { |
||||||
|
return { |
||||||
|
engaged: sortLibraryPublications( |
||||||
|
await filterEngagedPublications( |
||||||
|
getTopLevelIndexEvents(sessionCache.indexEvents), |
||||||
|
sessionCache.indexByAddress, |
||||||
|
sessionCache.engagement, |
||||||
|
relayUrls |
||||||
|
) |
||||||
|
), |
||||||
|
allIndexCount: sessionCache.indexEvents.length, |
||||||
|
topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const [indexEvents, engagement] = await Promise.all([ |
||||||
|
fetchLibraryIndexEvents(relayUrls), |
||||||
|
fetchPublicationEngagementMaps(relayUrls) |
||||||
|
]) |
||||||
|
const indexByAddress = buildIndexByAddress(indexEvents) |
||||||
|
sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } |
||||||
|
|
||||||
|
const topLevel = getTopLevelIndexEvents(indexEvents) |
||||||
|
const engaged = sortLibraryPublications( |
||||||
|
await filterEngagedPublications(topLevel, indexByAddress, engagement, relayUrls) |
||||||
|
) |
||||||
|
|
||||||
|
return { |
||||||
|
engaged, |
||||||
|
allIndexCount: indexEvents.length, |
||||||
|
topLevelCount: topLevel.length |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function clearLibraryPublicationIndexCache(): void { |
||||||
|
sessionCache = null |
||||||
|
} |
||||||
@ -0,0 +1,95 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
buildIndexByAddress, |
||||||
|
collectReachableAddresses, |
||||||
|
eventTagAddress, |
||||||
|
filterValidIndexEvents, |
||||||
|
getTopLevelIndexEvents |
||||||
|
} from '@/lib/publication-index' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const PK = 'a'.repeat(64) |
||||||
|
|
||||||
|
function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
kind: ExtendedKind.PUBLICATION, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 100, |
||||||
|
content: '', |
||||||
|
tags: [['d', d], ['title', `Title ${d}`], ...aTags.map((a) => ['a', a] as [string, string])], |
||||||
|
sig: 'c'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function contentEvent(d: string, id = d.padEnd(64, '1').slice(0, 64)): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
kind: ExtendedKind.PUBLICATION_CONTENT, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 100, |
||||||
|
content: 'section body', |
||||||
|
tags: [['d', d], ['title', `Section ${d}`]], |
||||||
|
sig: 'd'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('publication-index', () => { |
||||||
|
it('filterValidIndexEvents rejects non-NKBIP-01 indexes', () => { |
||||||
|
const valid = indexEvent('book', [`30041:${PK}:chapter-1`]) |
||||||
|
const withContent = { ...valid, content: 'not empty' } |
||||||
|
const noTitle = { ...valid, tags: [['d', 'book'], ['a', `30041:${PK}:chapter-1`]] } |
||||||
|
expect(filterValidIndexEvents([valid])).toHaveLength(1) |
||||||
|
expect(filterValidIndexEvents([withContent, noTitle])).toHaveLength(0) |
||||||
|
}) |
||||||
|
|
||||||
|
it('getTopLevelIndexEvents excludes nested 30040 children', () => { |
||||||
|
const childAddr = `30040:${PK}:part-1` |
||||||
|
const root = indexEvent('book', [childAddr, `30041:${PK}:intro`]) |
||||||
|
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64)) |
||||||
|
const top = getTopLevelIndexEvents([root, child]) |
||||||
|
expect(top).toHaveLength(1) |
||||||
|
expect(eventTagAddress(top[0])).toBe(`30040:${PK}:book`) |
||||||
|
}) |
||||||
|
|
||||||
|
it('collectReachableAddresses walks nested 30040 and 30041 refs', async () => { |
||||||
|
const childAddr = `30040:${PK}:part-1` |
||||||
|
const leafAddr = `30041:${PK}:chapter-1` |
||||||
|
const root = indexEvent('book', [childAddr, `30041:${PK}:intro`]) |
||||||
|
const child = indexEvent('part-1', [leafAddr], '2'.repeat(64)) |
||||||
|
const indexByAddress = buildIndexByAddress([root, child]) |
||||||
|
|
||||||
|
const reachable = await collectReachableAddresses( |
||||||
|
root, |
||||||
|
indexByAddress, |
||||||
|
async () => null |
||||||
|
) |
||||||
|
|
||||||
|
expect(reachable.has(`30040:${PK}:book`)).toBe(true) |
||||||
|
expect(reachable.has(childAddr)).toBe(true) |
||||||
|
expect(reachable.has(`30041:${PK}:intro`)).toBe(true) |
||||||
|
expect(reachable.has(leafAddr)).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('fetchMissingIndex resolves nested index not in initial cache', async () => { |
||||||
|
const childAddr = `30040:${PK}:part-1` |
||||||
|
const root = indexEvent('book', [childAddr]) |
||||||
|
const child = indexEvent('part-1', [`30041:${PK}:chapter-1`], '2'.repeat(64)) |
||||||
|
const indexByAddress = buildIndexByAddress([root]) |
||||||
|
|
||||||
|
const reachable = await collectReachableAddresses(root, indexByAddress, async (addr) => { |
||||||
|
if (addr === childAddr) return child |
||||||
|
return null |
||||||
|
}) |
||||||
|
|
||||||
|
expect(reachable.has(childAddr)).toBe(true) |
||||||
|
expect(reachable.has(`30041:${PK}:chapter-1`)).toBe(true) |
||||||
|
expect(indexByAddress.get(childAddr)?.id).toBe(child.id) |
||||||
|
}) |
||||||
|
|
||||||
|
it('eventTagAddress uses lowercase pubkey', () => { |
||||||
|
const ev = contentEvent('section-a') |
||||||
|
expect(eventTagAddress(ev)).toBe(`30041:${PK}:section-a`) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,142 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
batchFetchPublicationSectionEvents, |
||||||
|
parsePublicationATagCoordinate, |
||||||
|
type PublicationSectionRef |
||||||
|
} from '@/lib/publication-section-fetch' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export function eventTagAddress(event: Event): string | null { |
||||||
|
const d = event.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1] |
||||||
|
if (!d) return null |
||||||
|
return `${event.kind}:${event.pubkey.toLowerCase()}:${d}` |
||||||
|
} |
||||||
|
|
||||||
|
/** Removes kind 30040 index events that don't comply with NKBIP-01. */ |
||||||
|
export function filterValidIndexEvents(events: Event[]): Event[] { |
||||||
|
return events.filter((event) => { |
||||||
|
if (event.kind !== ExtendedKind.PUBLICATION) return false |
||||||
|
if (event.content != null && event.content.length > 0) return false |
||||||
|
const hasTitle = event.tags.some((t) => t[0] === 'title' && t[1]) |
||||||
|
const hasD = event.tags.some((t) => t[0] === 'd' && t[1]) |
||||||
|
const hasA = event.tags.some((t) => t[0] === 'a' && t[1]) |
||||||
|
const hasE = event.tags.some((t) => t[0] === 'e' && t[1]) |
||||||
|
return hasTitle && hasD && (hasA || hasE) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] { |
||||||
|
const refs: PublicationSectionRef[] = [] |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] !== 'a' || !tag[1]) continue |
||||||
|
const parsed = parsePublicationATagCoordinate(tag[1]) |
||||||
|
if (!parsed) continue |
||||||
|
refs.push({ |
||||||
|
type: 'a', |
||||||
|
coordinate: parsed.coordinate, |
||||||
|
kind: parsed.kind, |
||||||
|
pubkey: parsed.pubkey, |
||||||
|
identifier: parsed.identifier, |
||||||
|
relay: tag[2] |
||||||
|
}) |
||||||
|
} |
||||||
|
return refs |
||||||
|
} |
||||||
|
|
||||||
|
export function collectChildAddressesFromIndex(event: Event): string[] { |
||||||
|
const out: string[] = [] |
||||||
|
for (const ref of collectPublicationATagRefs(event)) { |
||||||
|
if ( |
||||||
|
ref.kind === ExtendedKind.PUBLICATION || |
||||||
|
ref.kind === ExtendedKind.PUBLICATION_CONTENT |
||||||
|
) { |
||||||
|
out.push(ref.coordinate!) |
||||||
|
} |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export function getReferencedChild30040Addresses(events: Event[]): Set<string> { |
||||||
|
const referenced = new Set<string>() |
||||||
|
for (const event of events) { |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] !== 'a' || !tag[1]) continue |
||||||
|
const parts = tag[1].split(':') |
||||||
|
if (parts.length >= 3 && parts[0] === String(ExtendedKind.PUBLICATION)) { |
||||||
|
referenced.add(tag[1]) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return referenced |
||||||
|
} |
||||||
|
|
||||||
|
export function getTopLevelIndexEvents(events: Event[]): Event[] { |
||||||
|
const referenced = getReferencedChild30040Addresses(events) |
||||||
|
return events.filter((event) => { |
||||||
|
const addr = eventTagAddress(event) |
||||||
|
return addr && !referenced.has(addr) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
export function buildIndexByAddress(events: Event[]): Map<string, Event> { |
||||||
|
const map = new Map<string, Event>() |
||||||
|
for (const event of events) { |
||||||
|
const addr = eventTagAddress(event) |
||||||
|
if (!addr) continue |
||||||
|
const prev = map.get(addr) |
||||||
|
if (!prev || event.created_at > prev.created_at) { |
||||||
|
map.set(addr, event) |
||||||
|
} |
||||||
|
} |
||||||
|
return map |
||||||
|
} |
||||||
|
|
||||||
|
export async function collectReachableAddresses( |
||||||
|
root: Event, |
||||||
|
indexByAddress: Map<string, Event>, |
||||||
|
fetchMissingIndex: (address: string) => Promise<Event | null> |
||||||
|
): Promise<Set<string>> { |
||||||
|
const reachable = new Set<string>() |
||||||
|
const rootAddr = eventTagAddress(root) |
||||||
|
if (!rootAddr) return reachable |
||||||
|
|
||||||
|
const queue = [rootAddr] |
||||||
|
while (queue.length > 0) { |
||||||
|
const addr = queue.shift()! |
||||||
|
if (reachable.has(addr)) continue |
||||||
|
reachable.add(addr) |
||||||
|
|
||||||
|
let event = indexByAddress.get(addr) |
||||||
|
if (!event) { |
||||||
|
const parsed = parsePublicationATagCoordinate(addr) |
||||||
|
if (parsed?.kind === ExtendedKind.PUBLICATION) { |
||||||
|
event = (await fetchMissingIndex(addr)) ?? undefined |
||||||
|
if (event) indexByAddress.set(addr, event) |
||||||
|
} |
||||||
|
} |
||||||
|
if (!event || event.kind !== ExtendedKind.PUBLICATION) continue |
||||||
|
|
||||||
|
for (const child of collectChildAddressesFromIndex(event)) { |
||||||
|
if (!reachable.has(child)) queue.push(child) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return reachable |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchMissingIndexByAddress( |
||||||
|
address: string, |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<Event | null> { |
||||||
|
const parsed = parsePublicationATagCoordinate(address) |
||||||
|
if (!parsed || parsed.kind !== ExtendedKind.PUBLICATION) return null |
||||||
|
const ref: PublicationSectionRef = { |
||||||
|
type: 'a', |
||||||
|
coordinate: parsed.coordinate, |
||||||
|
kind: parsed.kind, |
||||||
|
pubkey: parsed.pubkey, |
||||||
|
identifier: parsed.identifier |
||||||
|
} |
||||||
|
const fetched = await batchFetchPublicationSectionEvents([ref], relayUrls) |
||||||
|
return fetched.get(parsed.coordinate) ?? null |
||||||
|
} |
||||||
@ -0,0 +1,439 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { Lazy } from '@/lib/lazy' |
||||||
|
import { |
||||||
|
eventTagAddress, |
||||||
|
fetchMissingIndexByAddress |
||||||
|
} from '@/lib/publication-index' |
||||||
|
import { |
||||||
|
parsePublicationATagCoordinate, |
||||||
|
resolvePublicationEventIdToHex |
||||||
|
} from '@/lib/publication-section-fetch' |
||||||
|
import client, { queryService } from '@/services/client.service' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
enum PublicationTreeNodeType { |
||||||
|
Branch, |
||||||
|
Leaf |
||||||
|
} |
||||||
|
|
||||||
|
enum PublicationTreeNodeStatus { |
||||||
|
Resolved, |
||||||
|
Error |
||||||
|
} |
||||||
|
|
||||||
|
export enum TreeTraversalMode { |
||||||
|
Leaves, |
||||||
|
All |
||||||
|
} |
||||||
|
|
||||||
|
enum TreeTraversalDirection { |
||||||
|
Forward, |
||||||
|
Backward |
||||||
|
} |
||||||
|
|
||||||
|
interface PublicationTreeNode { |
||||||
|
type: PublicationTreeNodeType |
||||||
|
status: PublicationTreeNodeStatus |
||||||
|
address: string |
||||||
|
parent?: PublicationTreeNode |
||||||
|
children?: Array<Lazy<PublicationTreeNode>> |
||||||
|
} |
||||||
|
|
||||||
|
async function findIndexAsync<T>( |
||||||
|
array: T[], |
||||||
|
predicate: (element: T, index: number, array: T[]) => Promise<boolean> |
||||||
|
): Promise<number> { |
||||||
|
for (let i = 0; i < array.length; i++) { |
||||||
|
if (await predicate(array[i], i, array)) return i |
||||||
|
} |
||||||
|
return -1 |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchEventByAddress(address: string, relayUrls: string[]): Promise<Event | null> { |
||||||
|
const parsed = parsePublicationATagCoordinate(address) |
||||||
|
if (!parsed) return null |
||||||
|
const fromIndex = await fetchMissingIndexByAddress(address, relayUrls) |
||||||
|
if (fromIndex) return fromIndex |
||||||
|
const events = await queryService.fetchEvents( |
||||||
|
relayUrls, |
||||||
|
[ |
||||||
|
{ |
||||||
|
kinds: [parsed.kind], |
||||||
|
authors: [parsed.pubkey], |
||||||
|
'#d': [parsed.identifier], |
||||||
|
limit: 3 |
||||||
|
} |
||||||
|
], |
||||||
|
{ globalTimeout: 8000, eoseTimeout: 2000, firstRelayResultGraceMs: false } |
||||||
|
) |
||||||
|
if (events.length === 0) return null |
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)[0] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Lazy NKBIP-01 publication tree for nested 30040 → 30041 reading. |
||||||
|
* Adapted from Alexandria's PublicationTree. |
||||||
|
*/ |
||||||
|
export class PublicationTree implements AsyncIterable<Event | null> { |
||||||
|
#root: PublicationTreeNode |
||||||
|
#nodes: Map<string, Lazy<PublicationTreeNode>> |
||||||
|
#events: Map<string, Event> |
||||||
|
#eventCache = new Map<string, Event>() |
||||||
|
#bookmark?: string |
||||||
|
#visitedNodes = new Set<string>() |
||||||
|
#relayUrls: string[] |
||||||
|
#nodeAddedObservers: Array<(address: string) => void> = [] |
||||||
|
#nodeResolvedObservers: Array<(address: string) => void> = [] |
||||||
|
#bookmarkMovedObservers: Array<(address: string) => void> = [] |
||||||
|
|
||||||
|
constructor(rootEvent: Event, relayUrls: string[]) { |
||||||
|
const rootAddress = eventTagAddress(rootEvent) |
||||||
|
if (!rootAddress) { |
||||||
|
throw new Error('PublicationTree: root event has no d-tag address') |
||||||
|
} |
||||||
|
this.#root = { |
||||||
|
type: PublicationTreeNodeType.Branch, |
||||||
|
status: PublicationTreeNodeStatus.Resolved, |
||||||
|
address: rootAddress, |
||||||
|
children: [] |
||||||
|
} |
||||||
|
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>() |
||||||
|
this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))) |
||||||
|
this.#events = new Map<string, Event>() |
||||||
|
this.#events.set(rootAddress, rootEvent) |
||||||
|
this.#relayUrls = relayUrls |
||||||
|
} |
||||||
|
|
||||||
|
async getEvent(address: string): Promise<Event | null> { |
||||||
|
const cached = this.#events.get(address) |
||||||
|
if (cached) return cached |
||||||
|
return this.#depthFirstRetrieve(address) |
||||||
|
} |
||||||
|
|
||||||
|
async getChildAddresses(address: string): Promise<Array<string | null>> { |
||||||
|
const node = await this.#nodes.get(address)?.value() |
||||||
|
if (!node) { |
||||||
|
throw new Error(`[PublicationTree] Node with address ${address} not found.`) |
||||||
|
} |
||||||
|
return Promise.all( |
||||||
|
node.children?.map(async (child) => (await child.value())?.address ?? null) ?? [] |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
async getHierarchy(address: string): Promise<Event[]> { |
||||||
|
let node = await this.#nodes.get(address)?.value() |
||||||
|
if (!node) { |
||||||
|
throw new Error(`[PublicationTree] Node with address ${address} not found.`) |
||||||
|
} |
||||||
|
const hierarchy: Event[] = [this.#events.get(address)!] |
||||||
|
while (node.parent) { |
||||||
|
hierarchy.push(this.#events.get(node.parent.address)!) |
||||||
|
node = node.parent |
||||||
|
} |
||||||
|
return hierarchy.reverse() |
||||||
|
} |
||||||
|
|
||||||
|
setBookmark(address: string) { |
||||||
|
this.#bookmark = address |
||||||
|
void this.#cursor.tryMoveTo(address).then((success) => { |
||||||
|
if (success) { |
||||||
|
this.#bookmarkMovedObservers.forEach((observer) => observer(address)) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
resetCursor() { |
||||||
|
this.#bookmark = undefined |
||||||
|
this.#cursor.target = null |
||||||
|
} |
||||||
|
|
||||||
|
resetIterator() { |
||||||
|
this.resetCursor() |
||||||
|
this.#visitedNodes.clear() |
||||||
|
const rootAddress = this.#root.address |
||||||
|
this.#nodes.clear() |
||||||
|
this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))) |
||||||
|
this.#events.clear() |
||||||
|
this.#eventCache.clear() |
||||||
|
void this.#cursor.tryMoveTo() |
||||||
|
} |
||||||
|
|
||||||
|
onBookmarkMoved(observer: (address: string) => void) { |
||||||
|
this.#bookmarkMovedObservers.push(observer) |
||||||
|
} |
||||||
|
|
||||||
|
onNodeAdded(observer: (address: string) => void) { |
||||||
|
this.#nodeAddedObservers.push(observer) |
||||||
|
} |
||||||
|
|
||||||
|
onNodeResolved(observer: (address: string) => void) { |
||||||
|
this.#nodeResolvedObservers.push(observer) |
||||||
|
} |
||||||
|
|
||||||
|
#cursor = new (class { |
||||||
|
target: PublicationTreeNode | null | undefined |
||||||
|
#tree: PublicationTree |
||||||
|
|
||||||
|
constructor(tree: PublicationTree) { |
||||||
|
this.#tree = tree |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveTo(address?: string) { |
||||||
|
if (!address) { |
||||||
|
const startEvent = await this.#tree.#depthFirstRetrieve() |
||||||
|
if (!startEvent) return false |
||||||
|
const addr = eventTagAddress(startEvent) |
||||||
|
if (!addr) return false |
||||||
|
this.target = await this.#tree.#nodes.get(addr)?.value() |
||||||
|
} else { |
||||||
|
this.target = await this.#tree.#nodes.get(address)?.value() |
||||||
|
} |
||||||
|
return !!this.target |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToFirstChild(): Promise<boolean> { |
||||||
|
if (!this.target || this.target.type === PublicationTreeNodeType.Leaf) return false |
||||||
|
if (!this.target.children?.length) return false |
||||||
|
this.target = await this.target.children.at(0)?.value() |
||||||
|
return !!this.target |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToLastChild(): Promise<boolean> { |
||||||
|
if (!this.target || this.target.type === PublicationTreeNodeType.Leaf) return false |
||||||
|
if (!this.target.children?.length) return false |
||||||
|
this.target = await this.target.children.at(-1)?.value() |
||||||
|
return !!this.target |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToNextSibling(): Promise<boolean> { |
||||||
|
if (!this.target) return false |
||||||
|
const siblings = this.target.parent?.children |
||||||
|
if (!siblings) return false |
||||||
|
const currentIndex = await findIndexAsync(siblings, async (sibling) => { |
||||||
|
return (await sibling.value())?.address === this.target!.address |
||||||
|
}) |
||||||
|
if (currentIndex === -1 || currentIndex + 1 >= siblings.length) return false |
||||||
|
this.target = await siblings.at(currentIndex + 1)?.value() |
||||||
|
return !!this.target |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToPreviousSibling(): Promise<boolean> { |
||||||
|
if (!this.target) return false |
||||||
|
const siblings = this.target.parent?.children |
||||||
|
if (!siblings) return false |
||||||
|
const currentIndex = await findIndexAsync(siblings, async (sibling) => { |
||||||
|
return (await sibling.value())?.address === this.target!.address |
||||||
|
}) |
||||||
|
if (currentIndex <= 0) return false |
||||||
|
this.target = await siblings.at(currentIndex - 1)?.value() |
||||||
|
return !!this.target |
||||||
|
} |
||||||
|
|
||||||
|
tryMoveToParent(): boolean { |
||||||
|
if (!this.target?.parent) return false |
||||||
|
this.target = this.target.parent |
||||||
|
return true |
||||||
|
} |
||||||
|
})(this); |
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<Event | null> { |
||||||
|
return this |
||||||
|
} |
||||||
|
|
||||||
|
async next(mode: TreeTraversalMode = TreeTraversalMode.Leaves): Promise<IteratorResult<Event | null>> { |
||||||
|
if (!this.#cursor.target) { |
||||||
|
if (await this.#cursor.tryMoveTo(this.#bookmark)) { |
||||||
|
return this.#yieldEventAtCursor(false) |
||||||
|
} |
||||||
|
} |
||||||
|
switch (mode) { |
||||||
|
case TreeTraversalMode.Leaves: |
||||||
|
return this.#walkLeaves(TreeTraversalDirection.Forward) |
||||||
|
case TreeTraversalMode.All: |
||||||
|
return this.#preorderWalkAll(TreeTraversalDirection.Forward) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async previous(mode: TreeTraversalMode = TreeTraversalMode.Leaves): Promise<IteratorResult<Event | null>> { |
||||||
|
if (!this.#cursor.target) { |
||||||
|
if (await this.#cursor.tryMoveTo(this.#bookmark)) { |
||||||
|
return this.#yieldEventAtCursor(false) |
||||||
|
} |
||||||
|
} |
||||||
|
switch (mode) { |
||||||
|
case TreeTraversalMode.Leaves: |
||||||
|
return this.#walkLeaves(TreeTraversalDirection.Backward) |
||||||
|
case TreeTraversalMode.All: |
||||||
|
return this.#preorderWalkAll(TreeTraversalDirection.Backward) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async #yieldEventAtCursor(done: boolean): Promise<IteratorResult<Event | null>> { |
||||||
|
if (!this.#cursor.target) return { done, value: null } |
||||||
|
const address = this.#cursor.target.address |
||||||
|
if (this.#visitedNodes.has(address)) return { done: false, value: null } |
||||||
|
this.#visitedNodes.add(address) |
||||||
|
const value = (await this.getEvent(address)) ?? null |
||||||
|
return { done, value } |
||||||
|
} |
||||||
|
|
||||||
|
async #walkLeaves(direction: TreeTraversalDirection): Promise<IteratorResult<Event | null>> { |
||||||
|
const tryMoveToSibling = |
||||||
|
direction === TreeTraversalDirection.Forward |
||||||
|
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) |
||||||
|
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor) |
||||||
|
const tryMoveToChild = |
||||||
|
direction === TreeTraversalDirection.Forward |
||||||
|
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) |
||||||
|
: this.#cursor.tryMoveToLastChild.bind(this.#cursor) |
||||||
|
|
||||||
|
do { |
||||||
|
if (await tryMoveToSibling()) { |
||||||
|
while (await tryMoveToChild()) continue |
||||||
|
if (this.#cursor.target?.status === PublicationTreeNodeStatus.Error) { |
||||||
|
return { done: false, value: null } |
||||||
|
} |
||||||
|
return this.#yieldEventAtCursor(false) |
||||||
|
} |
||||||
|
} while (this.#cursor.tryMoveToParent()) |
||||||
|
|
||||||
|
return { done: true, value: null } |
||||||
|
} |
||||||
|
|
||||||
|
async #preorderWalkAll(direction: TreeTraversalDirection): Promise<IteratorResult<Event | null>> { |
||||||
|
const tryMoveToSibling = |
||||||
|
direction === TreeTraversalDirection.Forward |
||||||
|
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) |
||||||
|
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor) |
||||||
|
const tryMoveToChild = |
||||||
|
direction === TreeTraversalDirection.Forward |
||||||
|
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) |
||||||
|
: this.#cursor.tryMoveToLastChild.bind(this.#cursor) |
||||||
|
|
||||||
|
if (await tryMoveToChild()) return this.#yieldEventAtCursor(false) |
||||||
|
do { |
||||||
|
if (await tryMoveToSibling()) return this.#yieldEventAtCursor(false) |
||||||
|
} while (this.#cursor.tryMoveToParent()) |
||||||
|
return this.#yieldEventAtCursor(true) |
||||||
|
} |
||||||
|
|
||||||
|
async #depthFirstRetrieve(address?: string): Promise<Event | null> { |
||||||
|
if (address && this.#nodes.has(address)) { |
||||||
|
return this.#events.get(address) ?? null |
||||||
|
} |
||||||
|
|
||||||
|
const stack: string[] = [this.#root.address] |
||||||
|
while (stack.length > 0) { |
||||||
|
const currentAddress = stack.pop()! |
||||||
|
const currentNode = await this.#nodes.get(currentAddress)?.value() |
||||||
|
if (!currentNode) return null |
||||||
|
|
||||||
|
let currentEvent = this.#events.get(currentAddress) |
||||||
|
if (!currentEvent) return null |
||||||
|
if (address != null && currentAddress === address) return currentEvent |
||||||
|
|
||||||
|
let currentChildAddresses = currentEvent.tags |
||||||
|
.filter((tag) => tag[0] === 'a' && tag[1]) |
||||||
|
.map((tag) => tag[1]) |
||||||
|
|
||||||
|
if (currentChildAddresses.length === 0) { |
||||||
|
const eTags = currentEvent.tags.filter( |
||||||
|
(tag) => tag[0] === 'e' && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]) |
||||||
|
) |
||||||
|
const resolved = await Promise.all( |
||||||
|
eTags.map(async (tag) => { |
||||||
|
const hex = resolvePublicationEventIdToHex(tag[1]) |
||||||
|
if (!hex) return null |
||||||
|
const ev = await client.fetchEvent(hex) |
||||||
|
if (!ev) return null |
||||||
|
return eventTagAddress(ev) |
||||||
|
}) |
||||||
|
) |
||||||
|
currentChildAddresses = resolved.filter((a): a is string => !!a) |
||||||
|
} |
||||||
|
|
||||||
|
if (currentChildAddresses.length === 0) { |
||||||
|
if (address == null) return currentEvent |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
await Promise.all( |
||||||
|
currentChildAddresses |
||||||
|
.filter((childAddress) => !this.#nodes.has(childAddress)) |
||||||
|
.map((childAddress) => this.#addNode(childAddress, currentNode)) |
||||||
|
) |
||||||
|
|
||||||
|
while (currentChildAddresses.length > 0) { |
||||||
|
stack.push(currentChildAddresses.pop()!) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
#addNode(address: string, parentNode: PublicationTreeNode) { |
||||||
|
const lazyNode = new Lazy(() => this.#resolveNode(address, parentNode)) |
||||||
|
parentNode.children!.push(lazyNode) |
||||||
|
this.#nodes.set(address, lazyNode) |
||||||
|
this.#nodeAddedObservers.forEach((observer) => observer(address)) |
||||||
|
} |
||||||
|
|
||||||
|
async #resolveNode(address: string, parentNode: PublicationTreeNode): Promise<PublicationTreeNode> { |
||||||
|
let event = this.#eventCache.get(address) |
||||||
|
if (!event) { |
||||||
|
event = (await fetchEventByAddress(address, this.#relayUrls)) ?? undefined |
||||||
|
if (event) this.#eventCache.set(address, event) |
||||||
|
} |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
return { |
||||||
|
type: PublicationTreeNodeType.Leaf, |
||||||
|
status: PublicationTreeNodeStatus.Error, |
||||||
|
address, |
||||||
|
parent: parentNode, |
||||||
|
children: [] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return this.#buildNodeFromEvent(event, address, parentNode) |
||||||
|
} |
||||||
|
|
||||||
|
async #buildNodeFromEvent( |
||||||
|
event: Event, |
||||||
|
address: string, |
||||||
|
parentNode: PublicationTreeNode |
||||||
|
): Promise<PublicationTreeNode> { |
||||||
|
this.#events.set(address, event) |
||||||
|
const childAddresses = event.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) |
||||||
|
const node: PublicationTreeNode = { |
||||||
|
type: this.#getNodeType(event), |
||||||
|
status: PublicationTreeNodeStatus.Resolved, |
||||||
|
address, |
||||||
|
parent: parentNode, |
||||||
|
children: [] |
||||||
|
} |
||||||
|
for (const childAddress of childAddresses) { |
||||||
|
this.#addNode(childAddress, node) |
||||||
|
} |
||||||
|
this.#nodeResolvedObservers.forEach((observer) => observer(address)) |
||||||
|
return node |
||||||
|
} |
||||||
|
|
||||||
|
#getNodeType(event: Event): PublicationTreeNodeType { |
||||||
|
if (event.kind === ExtendedKind.PUBLICATION) { |
||||||
|
const hasChildren = event.tags.some((tag) => tag[0] === 'a') |
||||||
|
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf |
||||||
|
} |
||||||
|
if ( |
||||||
|
event.kind === ExtendedKind.PUBLICATION_CONTENT || |
||||||
|
event.kind === 30818 || |
||||||
|
event.kind === 30023 |
||||||
|
) { |
||||||
|
return PublicationTreeNodeType.Leaf |
||||||
|
} |
||||||
|
const hasChildren = event.tags.some((tag) => tag[0] === 'a') |
||||||
|
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export { fetchEventByAddress as fetchPublicationTreeEventByAddress } |
||||||
@ -0,0 +1,98 @@ |
|||||||
|
import LibraryPublicationGrid from '@/components/Library/LibraryPublicationGrid' |
||||||
|
import LibrarySearchBar from '@/components/Library/LibrarySearchBar' |
||||||
|
import { RefreshButton } from '@/components/RefreshButton' |
||||||
|
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' |
||||||
|
import { useLibraryPublications } from '@/hooks/useLibraryPublications' |
||||||
|
import { usePrimaryPage } from '@/contexts/primary-page-context' |
||||||
|
import { TPageRef } from '@/types' |
||||||
|
import { BookOpen } from 'lucide-react' |
||||||
|
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const LibraryPage = forwardRef<TPageRef>((_props, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { current, display } = usePrimaryPage() |
||||||
|
const isActive = useMemo(() => current === 'library' && display, [current, display]) |
||||||
|
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) |
||||||
|
const { |
||||||
|
entries, |
||||||
|
searchQuery, |
||||||
|
setSearchQuery, |
||||||
|
showOnlyMine, |
||||||
|
setShowOnlyMine, |
||||||
|
loading, |
||||||
|
error, |
||||||
|
allIndexCount, |
||||||
|
topLevelCount, |
||||||
|
refresh |
||||||
|
} = useLibraryPublications(isActive) |
||||||
|
|
||||||
|
useImperativeHandle( |
||||||
|
ref, |
||||||
|
() => ({ |
||||||
|
scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior), |
||||||
|
refresh |
||||||
|
}), |
||||||
|
[refresh] |
||||||
|
) |
||||||
|
|
||||||
|
const statusLine = |
||||||
|
!loading && !error |
||||||
|
? t('Library status line', { |
||||||
|
shown: entries.length, |
||||||
|
topLevel: topLevelCount, |
||||||
|
total: allIndexCount |
||||||
|
}) |
||||||
|
: null |
||||||
|
|
||||||
|
return ( |
||||||
|
<PrimaryPageLayout |
||||||
|
ref={layoutRef} |
||||||
|
pageName="library" |
||||||
|
titlebar={<LibraryPageTitlebar onRefresh={refresh} />} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<div className="min-w-0 px-4 pb-4 pt-4"> |
||||||
|
<div className="mb-4"> |
||||||
|
<LibrarySearchBar |
||||||
|
searchQuery={searchQuery} |
||||||
|
onSearchQueryChange={setSearchQuery} |
||||||
|
showOnlyMine={showOnlyMine} |
||||||
|
onShowOnlyMineChange={setShowOnlyMine} |
||||||
|
disabled={loading} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{error ? ( |
||||||
|
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
{statusLine ? ( |
||||||
|
<p className="mb-4 text-xs text-muted-foreground">{statusLine}</p> |
||||||
|
) : null} |
||||||
|
<LibraryPublicationGrid |
||||||
|
entries={entries} |
||||||
|
loading={loading} |
||||||
|
emptyMessage={ |
||||||
|
searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') |
||||||
|
} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
}) |
||||||
|
LibraryPage.displayName = 'LibraryPage' |
||||||
|
export default LibraryPage |
||||||
|
|
||||||
|
function LibraryPageTitlebar({ onRefresh }: { onRefresh: () => void }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
return ( |
||||||
|
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> |
||||||
|
<div className="flex items-center gap-2 pl-3"> |
||||||
|
<BookOpen className="size-5" /> |
||||||
|
<div className="app-chrome-title">{t('Library page title')}</div> |
||||||
|
</div> |
||||||
|
<RefreshButton onClick={onRefresh} /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue