Browse Source

Implement book page

imwald
Silberengel 1 week ago
parent
commit
c82982ad7e
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 9
      src/PageManager.tsx
  4. 80
      src/components/Library/LibraryPublicationGrid.tsx
  5. 49
      src/components/Library/LibrarySearchBar.tsx
  6. 47
      src/components/Sidebar/LibraryButton.tsx
  7. 2
      src/components/Sidebar/index.tsx
  8. 8
      src/constants.ts
  9. 86
      src/hooks/useLibraryPublications.ts
  10. 10
      src/i18n/locales/de.ts
  11. 10
      src/i18n/locales/en.ts
  12. 45
      src/lib/lazy.test.ts
  13. 48
      src/lib/lazy.ts
  14. 84
      src/lib/library-publication-index.test.ts
  15. 326
      src/lib/library-publication-index.ts
  16. 95
      src/lib/publication-index.test.ts
  17. 142
      src/lib/publication-index.ts
  18. 439
      src/lib/publication-tree.ts
  19. 98
      src/pages/primary/LibraryPage/index.tsx
  20. 2
      src/pages/primary/NoteListPage/index.tsx
  21. 3
      src/routes.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.19.2", "version": "23.21.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.19.2", "version": "23.21.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.20.0", "version": "23.21.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

9
src/PageManager.tsx

@ -96,6 +96,7 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage')) const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage')) const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const LibraryPageLazy = lazy(() => import('./pages/primary/LibraryPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage')) const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage'))
@ -146,6 +147,7 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(), profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(), relay: createRef<TPageRef>(),
search: createRef<TPageRef>(), search: createRef<TPageRef>(),
library: createRef<TPageRef>(),
rss: createRef<TPageRef>(), rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(), settings: createRef<TPageRef>(),
spells: createRef<TPageRef>(), spells: createRef<TPageRef>(),
@ -185,6 +187,11 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} /> <SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense> </Suspense>
), ),
library: (
<Suspense fallback={primaryPageLazyFallback}>
<LibraryPageLazy ref={PRIMARY_PAGE_REF_MAP.library} />
</Suspense>
),
rss: ( rss: (
<Suspense fallback={primaryPageLazyFallback}> <Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} /> <RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -300,6 +307,7 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
// Pages that should preserve context in the URL // Pages that should preserve context in the URL
const contextualPages: TPrimaryPageName[] = [ const contextualPages: TPrimaryPageName[] = [
'search', 'search',
'library',
'profile', 'profile',
'feed', 'feed',
'spells', 'spells',
@ -323,6 +331,7 @@ function buildRssArticleUrl(
const key = encodeRssArticlePathSegment(articleUrl) const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = [ const contextualPages: TPrimaryPageName[] = [
'search', 'search',
'library',
'profile', 'profile',
'feed', 'feed',
'spells', 'spells',

80
src/components/Library/LibraryPublicationGrid.tsx

@ -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>
)
}

49
src/components/Library/LibrarySearchBar.tsx

@ -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>
)
}

47
src/components/Sidebar/LibraryButton.tsx

@ -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>
)
}

2
src/components/Sidebar/index.tsx

@ -8,6 +8,7 @@ import NotificationButton from './NotificationButton'
import PostButton from './PostButton' import PostButton from './PostButton'
import RssButton from './RssButton' import RssButton from './RssButton'
import SearchButton from './SearchButton' import SearchButton from './SearchButton'
import LibraryButton from './LibraryButton'
import FavoritesButton from './FavoritesButton' import FavoritesButton from './FavoritesButton'
import DiscussionsButton from './DiscussionsButton' import DiscussionsButton from './DiscussionsButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
@ -42,6 +43,7 @@ export default function PrimaryPageSidebar() {
<FeedButton /> <FeedButton />
<NotificationButton /> <NotificationButton />
<SearchButton /> <SearchButton />
<LibraryButton />
<FavoritesButton /> <FavoritesButton />
<DiscussionsButton /> <DiscussionsButton />
<SpellsButton /> <SpellsButton />

8
src/constants.ts

@ -460,6 +460,12 @@ export const DOCUMENT_RELAY_URLS = [
'wss://essayist.decentnewsroom.com' 'wss://essayist.decentnewsroom.com'
] as const ] as const
/** Document + mercury index relays for Library publication browsing. */
export const LIBRARY_RELAY_URLS = [
...DOCUMENT_RELAY_URLS,
'https://mercury-relay.imwald.eu/'
] as const
/** /**
* Relays that must never receive publishes: search engines, index mirrors, and similar endpoints that only ingest * Relays that must never receive publishes: search engines, index mirrors, and similar endpoints that only ingest
* or aggregate for read. Use only to strip URLs from publish / write / publish-picker paths do not prepend this * or aggregate for read. Use only to strip URLs from publish / write / publish-picker paths do not prepend this
@ -594,6 +600,8 @@ export const ExtendedKind = {
ZAP_POLL: 6969, ZAP_POLL: 6969,
POLL_RESPONSE: 1018, POLL_RESPONSE: 1018,
COMMENT: 1111, COMMENT: 1111,
/** NIP-32 label events. */
LABEL: 1985,
VOICE: 1222, VOICE: 1222,
VOICE_COMMENT: 1244, VOICE_COMMENT: 1244,
PUBLIC_MESSAGE: 24, PUBLIC_MESSAGE: 24,

86
src/hooks/useLibraryPublications.ts

@ -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
}
}

10
src/i18n/locales/de.ts

@ -1647,6 +1647,16 @@ export default {
'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows', 'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows',
'Search page title': 'Nostr durchsuchen', 'Search page title': 'Nostr durchsuchen',
'Search on Alexandria': 'Mit Alexandria suchen', 'Search on Alexandria': 'Mit Alexandria suchen',
Library: 'Bibliothek',
'Library page title': 'Bibliothek',
'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…',
'Library show only my publications': 'Nur meine Publikationen',
'Library empty': 'Noch keine Publikationen mit Interaktionen auf deinen Relays.',
'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.',
'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen',
'Library badge label': 'Label',
'Library badge comment': 'Kommentar',
'Library badge highlight': 'Markierung',
'Search page clear': 'Leeren', 'Search page clear': 'Leeren',
'Search page clear description': 'Search page clear description':
'Suchfeld leeren, Vorschläge schließen und Ergebnisse entfernen, um neu zu suchen.', 'Suchfeld leeren, Vorschläge schließen und Ergebnisse entfernen, um neu zu suchen.',

10
src/i18n/locales/en.ts

@ -1670,6 +1670,16 @@ export default {
'Latest from our recommended follows': 'Latest from our recommended follows', 'Latest from our recommended follows': 'Latest from our recommended follows',
'Search page title': 'Search Nostr', 'Search page title': 'Search Nostr',
'Search on Alexandria': 'Search on Alexandria', 'Search on Alexandria': 'Search on Alexandria',
Library: 'Library',
'Library page title': 'Library',
'Library search placeholder': 'Search publications by title, author, or tag…',
'Library show only my publications': 'Show only my publications',
'Library empty': 'No engaged publications found on your relays yet.',
'Library empty filtered': 'No publications match your filters.',
'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',
'Library badge label': 'Label',
'Library badge comment': 'Comment',
'Library badge highlight': 'Highlight',
'Search page clear': 'Clear', 'Search page clear': 'Clear',
'Search page clear description': 'Search page clear description':
'Clear the search field, close suggestions, and remove results so you can start a new search.', 'Clear the search field, close suggestions, and remove results so you can start a new search.',

45
src/lib/lazy.test.ts

@ -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)
})
})

48
src/lib/lazy.ts

@ -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
}
}
}

84
src/lib/library-publication-index.test.ts

@ -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)
})
})

326
src/lib/library-publication-index.ts

@ -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
}

95
src/lib/publication-index.test.ts

@ -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`)
})
})

142
src/lib/publication-index.ts

@ -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
}

439
src/lib/publication-tree.ts

@ -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 }

98
src/pages/primary/LibraryPage/index.tsx

@ -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>
)
}

2
src/pages/primary/NoteListPage/index.tsx

@ -21,6 +21,7 @@ import React, {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Logo from '@/assets/Logo' import Logo from '@/assets/Logo'
import { DiscussionsTitlebarButton } from '@/components/Sidebar/DiscussionsButton' import { DiscussionsTitlebarButton } from '@/components/Sidebar/DiscussionsButton'
import { LibraryTitlebarButton } from '@/components/Sidebar/LibraryButton'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
@ -193,6 +194,7 @@ function NoteListPageTitlebar({
<Logo className="max-h-7 w-full min-w-0 object-contain object-center sm:max-h-8" /> <Logo className="max-h-7 w-full min-w-0 object-contain object-center sm:max-h-8" />
</button> </button>
<DiscussionsTitlebarButton /> <DiscussionsTitlebarButton />
<LibraryTitlebarButton />
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"

3
src/routes.tsx

@ -66,7 +66,7 @@ const rssArticlePageElement = SR(RssArticlePageLazy)
/** Primary segments used in contextual `/…/notes/:id` and `/…/rss-item/:key` routes. */ /** Primary segments used in contextual `/…/notes/:id` and `/…/rss-item/:key` routes. */
const CONTEXTUAL_ROUTE_PREFIXES = const CONTEXTUAL_ROUTE_PREFIXES =
'discussions|search|profile|home|feed|spells|explore|rss|calendar' 'discussions|search|library|profile|home|feed|spells|explore|rss|calendar'
const contextualNotePathRe = new RegExp( const contextualNotePathRe = new RegExp(
`^/(${CONTEXTUAL_ROUTE_PREFIXES})/notes/([^/?#]+)$` `^/(${CONTEXTUAL_ROUTE_PREFIXES})/notes/([^/?#]+)$`
@ -82,6 +82,7 @@ const ROUTES = [
{ path: '/notes/:id', element: notePageElement }, { path: '/notes/:id', element: notePageElement },
{ path: '/discussions/notes/:id', element: notePageElement }, { path: '/discussions/notes/:id', element: notePageElement },
{ path: '/search/notes/:id', element: notePageElement }, { path: '/search/notes/:id', element: notePageElement },
{ path: '/library/notes/:id', element: notePageElement },
{ path: '/profile/notes/:id', element: notePageElement }, { path: '/profile/notes/:id', element: notePageElement },
{ path: '/explore/notes/:id', element: notePageElement }, { path: '/explore/notes/:id', element: notePageElement },
{ path: '/home/notes/:id', element: notePageElement }, { path: '/home/notes/:id', element: notePageElement },

Loading…
Cancel
Save