From c82982ad7eb5ccd5109dcdc10f8823180f983b8a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 16:08:23 +0200 Subject: [PATCH] Implement book page --- package-lock.json | 4 +- package.json | 2 +- src/PageManager.tsx | 9 + .../Library/LibraryPublicationGrid.tsx | 80 ++++ src/components/Library/LibrarySearchBar.tsx | 49 ++ src/components/Sidebar/LibraryButton.tsx | 47 ++ src/components/Sidebar/index.tsx | 2 + src/constants.ts | 8 + src/hooks/useLibraryPublications.ts | 86 ++++ src/i18n/locales/de.ts | 10 + src/i18n/locales/en.ts | 10 + src/lib/lazy.test.ts | 45 ++ src/lib/lazy.ts | 48 ++ src/lib/library-publication-index.test.ts | 84 ++++ src/lib/library-publication-index.ts | 326 +++++++++++++ src/lib/publication-index.test.ts | 95 ++++ src/lib/publication-index.ts | 142 ++++++ src/lib/publication-tree.ts | 439 ++++++++++++++++++ src/pages/primary/LibraryPage/index.tsx | 98 ++++ src/pages/primary/NoteListPage/index.tsx | 2 + src/routes.tsx | 3 +- 21 files changed, 1585 insertions(+), 4 deletions(-) create mode 100644 src/components/Library/LibraryPublicationGrid.tsx create mode 100644 src/components/Library/LibrarySearchBar.tsx create mode 100644 src/components/Sidebar/LibraryButton.tsx create mode 100644 src/hooks/useLibraryPublications.ts create mode 100644 src/lib/lazy.test.ts create mode 100644 src/lib/lazy.ts create mode 100644 src/lib/library-publication-index.test.ts create mode 100644 src/lib/library-publication-index.ts create mode 100644 src/lib/publication-index.test.ts create mode 100644 src/lib/publication-index.ts create mode 100644 src/lib/publication-tree.ts create mode 100644 src/pages/primary/LibraryPage/index.tsx diff --git a/package-lock.json b/package-lock.json index 69c0ca5c..4270857b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.19.2", + "version": "23.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.19.2", + "version": "23.21.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index a8bef0d9..8fde1f7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 0cea87cc..e66b8689 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -96,6 +96,7 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage')) const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage')) const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage')) const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) +const LibraryPageLazy = lazy(() => import('./pages/primary/LibraryPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage')) @@ -146,6 +147,7 @@ const PRIMARY_PAGE_REF_MAP = { profile: createRef(), relay: createRef(), search: createRef(), + library: createRef(), rss: createRef(), settings: createRef(), spells: createRef(), @@ -185,6 +187,11 @@ const getPrimaryPageMap = () => ({ ), + library: ( + + + + ), rss: ( @@ -300,6 +307,7 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str // Pages that should preserve context in the URL const contextualPages: TPrimaryPageName[] = [ 'search', + 'library', 'profile', 'feed', 'spells', @@ -323,6 +331,7 @@ function buildRssArticleUrl( const key = encodeRssArticlePathSegment(articleUrl) const contextualPages: TPrimaryPageName[] = [ 'search', + 'library', 'profile', 'feed', 'spells', diff --git a/src/components/Library/LibraryPublicationGrid.tsx b/src/components/Library/LibraryPublicationGrid.tsx new file mode 100644 index 00000000..161c593b --- /dev/null +++ b/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 ( +
+ {entry.hasLabel && ( + + + {t('Library badge label')} + + )} + {entry.hasComment && ( + + + {t('Library badge comment')} + + )} + {entry.hasHighlight && ( + + + {t('Library badge highlight')} + + )} +
+ ) +} + +export default function LibraryPublicationGrid({ + entries, + loading, + emptyMessage +}: { + entries: LibraryPublicationEntry[] + loading?: boolean + emptyMessage?: string +}) { + const { t } = useTranslation() + + if (loading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } + + if (entries.length === 0) { + return ( +
+ {emptyMessage ?? t('Library empty')} +
+ ) + } + + return ( +
+ {entries.map((entry) => ( +
+ + +
+ ))} +
+ ) +} diff --git a/src/components/Library/LibrarySearchBar.tsx b/src/components/Library/LibrarySearchBar.tsx new file mode 100644 index 00000000..263a6645 --- /dev/null +++ b/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 ( +
+
+ + onSearchQueryChange(e.target.value)} + placeholder={t('Library search placeholder')} + className="pl-9" + disabled={disabled} + aria-label={t('Library search placeholder')} + /> +
+
+ + +
+
+ ) +} diff --git a/src/components/Sidebar/LibraryButton.tsx b/src/components/Sidebar/LibraryButton.tsx new file mode 100644 index 00000000..6ee9658d --- /dev/null +++ b/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 ( + navigate('library')} + active={current === 'library' && display && primaryViewType === null} + > + + + ) +} + +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 ( + + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 7641ce55..81276f74 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -8,6 +8,7 @@ import NotificationButton from './NotificationButton' import PostButton from './PostButton' import RssButton from './RssButton' import SearchButton from './SearchButton' +import LibraryButton from './LibraryButton' import FavoritesButton from './FavoritesButton' import DiscussionsButton from './DiscussionsButton' import SpellsButton from './SpellsButton' @@ -42,6 +43,7 @@ export default function PrimaryPageSidebar() { + diff --git a/src/constants.ts b/src/constants.ts index cf748d8e..9e7601bf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -460,6 +460,12 @@ export const DOCUMENT_RELAY_URLS = [ 'wss://essayist.decentnewsroom.com' ] 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 * 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, POLL_RESPONSE: 1018, COMMENT: 1111, + /** NIP-32 label events. */ + LABEL: 1985, VOICE: 1222, VOICE_COMMENT: 1244, PUBLIC_MESSAGE: 24, diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts new file mode 100644 index 00000000..3e44a9eb --- /dev/null +++ b/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([]) + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [showOnlyMine, setShowOnlyMine] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 + } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 6b93dc29..3aafaf9e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1647,6 +1647,16 @@ export default { 'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows', 'Search page title': 'Nostr durchsuchen', '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 description': 'Suchfeld leeren, Vorschläge schließen und Ergebnisse entfernen, um neu zu suchen.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3343561c..bf80dac3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1670,6 +1670,16 @@ export default { 'Latest from our recommended follows': 'Latest from our recommended follows', 'Search page title': 'Search Nostr', '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 description': 'Clear the search field, close suggestions, and remove results so you can start a new search.', diff --git a/src/lib/lazy.test.ts b/src/lib/lazy.test.ts new file mode 100644 index 00000000..dde4f89a --- /dev/null +++ b/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((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) + }) +}) diff --git a/src/lib/lazy.ts b/src/lib/lazy.ts new file mode 100644 index 00000000..1102fcee --- /dev/null +++ b/src/lib/lazy.ts @@ -0,0 +1,48 @@ +export enum LazyStatus { + Pending, + Resolved, + Error +} + +export class Lazy { + #value: T | null = null + #resolver: () => Promise + #pendingPromise: Promise | null = null + + status: LazyStatus + + constructor(resolver: () => Promise) { + this.#resolver = resolver + this.status = LazyStatus.Pending + } + + value(): Promise { + 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 { + try { + this.#value = await this.#resolver() + this.status = LazyStatus.Resolved + return this.#value + } catch { + this.status = LazyStatus.Error + return null + } finally { + this.#pendingPromise = null + } + } +} diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts new file mode 100644 index 00000000..f4fb8450 --- /dev/null +++ b/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) + }) +}) diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts new file mode 100644 index 00000000..ce3d54d5 --- /dev/null +++ b/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 + labelEventIds: Set + commentAddresses: Set + highlightAddresses: Set +} + +export type LibraryPublicationEntry = { + event: Event + hasLabel: boolean + hasComment: boolean + hasHighlight: boolean + engagementCount: number +} + +type LibraryIndexCache = { + relayKey: string + indexEvents: Event[] + indexByAddress: Map + 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 { + 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() + 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 { + 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() + const labelEventIds = new Set() + const commentAddresses = new Set() + const highlightAddresses = new Set() + + 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 { + 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, + engagement: PublicationEngagementMaps, + relayUrls: string[] +): Promise { + 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 +} diff --git a/src/lib/publication-index.test.ts b/src/lib/publication-index.test.ts new file mode 100644 index 00000000..a3fb8da6 --- /dev/null +++ b/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`) + }) +}) diff --git a/src/lib/publication-index.ts b/src/lib/publication-index.ts new file mode 100644 index 00000000..96674e54 --- /dev/null +++ b/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 { + const referenced = new Set() + 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 { + const map = new Map() + 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, + fetchMissingIndex: (address: string) => Promise +): Promise> { + const reachable = new Set() + 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 { + 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 +} diff --git a/src/lib/publication-tree.ts b/src/lib/publication-tree.ts new file mode 100644 index 00000000..1028b7e1 --- /dev/null +++ b/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> +} + +async function findIndexAsync( + array: T[], + predicate: (element: T, index: number, array: T[]) => Promise +): Promise { + 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 { + 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 { + #root: PublicationTreeNode + #nodes: Map> + #events: Map + #eventCache = new Map() + #bookmark?: string + #visitedNodes = new Set() + #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>() + this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))) + this.#events = new Map() + this.#events.set(rootAddress, rootEvent) + this.#relayUrls = relayUrls + } + + async getEvent(address: string): Promise { + const cached = this.#events.get(address) + if (cached) return cached + return this.#depthFirstRetrieve(address) + } + + async getChildAddresses(address: string): Promise> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this + } + + async next(mode: TreeTraversalMode = TreeTraversalMode.Leaves): Promise> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 } diff --git a/src/pages/primary/LibraryPage/index.tsx b/src/pages/primary/LibraryPage/index.tsx new file mode 100644 index 00000000..a861377f --- /dev/null +++ b/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((_props, ref) => { + const { t } = useTranslation() + const { current, display } = usePrimaryPage() + const isActive = useMemo(() => current === 'library' && display, [current, display]) + const layoutRef = useRef(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 ( + } + displayScrollToTopButton + > +
+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + {statusLine ? ( +

{statusLine}

+ ) : null} + +
+
+ ) +}) +LibraryPage.displayName = 'LibraryPage' +export default LibraryPage + +function LibraryPageTitlebar({ onRefresh }: { onRefresh: () => void }) { + const { t } = useTranslation() + return ( +
+
+ +
{t('Library page title')}
+
+ +
+ ) +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index f585969c..bc6b9cd5 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -21,6 +21,7 @@ import React, { import { useTranslation } from 'react-i18next' import Logo from '@/assets/Logo' import { DiscussionsTitlebarButton } from '@/components/Sidebar/DiscussionsButton' +import { LibraryTitlebarButton } from '@/components/Sidebar/LibraryButton' import RelaysFeed from './RelaysFeed' import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' @@ -193,6 +194,7 @@ function NoteListPageTitlebar({ +