21 changed files with 1585 additions and 4 deletions
@ -0,0 +1,80 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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