From 035ef934d5be9169615ee726a5835fe1e9a2ef19 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sat, 3 May 2025 22:54:13 -0500 Subject: [PATCH 01/17] Support loading single content events Handles the case where the root of a publication tree is identical to its one and only leaf, i.e., the publication consists of a single event. --- src/lib/components/Publication.svelte | 2 +- src/lib/data_structures/publication_tree.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 7d53987..58f29db 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -39,7 +39,7 @@ for (let i = 0; i < count; i++) { const nextItem = await publicationTree.next(); - if (leaves.includes(nextItem.value) || nextItem.done) { + if (leaves.includes(nextItem.value) || (nextItem.done && nextItem.value === null)) { isLoading = false; return; } diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 7f6c968..466e676 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -4,7 +4,6 @@ import { Lazy } from "./lazy.ts"; import { findIndexAsync as _findIndexAsync } from '../utils.ts'; enum PublicationTreeNodeType { - Root, Branch, Leaf, } @@ -50,7 +49,7 @@ export class PublicationTree implements AsyncIterable { constructor(rootEvent: NDKEvent, ndk: NDK) { const rootAddress = rootEvent.tagAddress(); this.#root = { - type: PublicationTreeNodeType.Root, + type: this.#getNodeType(rootEvent), address: rootAddress, children: [], }; @@ -286,9 +285,15 @@ export class PublicationTree implements AsyncIterable { continue; } - if (this.#cursor.target?.type === PublicationTreeNodeType.Root) { + const isRoot = this.#cursor.target?.address === this.#root.address; + + if (isRoot && this.#cursor.target?.type === PublicationTreeNodeType.Branch) { return { done: true, value: null }; } + + if (isRoot && this.#cursor.target?.type === PublicationTreeNodeType.Leaf) { + return { done: true, value: this.#events.get(this.#cursor.target!.address)! }; + } } while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf); const event = await this.getEvent(this.#cursor.target!.address); @@ -414,11 +419,7 @@ export class PublicationTree implements AsyncIterable { return node; } - async #getNodeType(event: NDKEvent): Promise { - if (event.tagAddress() === this.#root.address) { - return PublicationTreeNodeType.Root; - } - + #getNodeType(event: NDKEvent): PublicationTreeNodeType { if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { return PublicationTreeNodeType.Branch; } From adada4efd91855ac7742e952c0fc71081ca0f112 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sat, 3 May 2025 23:13:11 -0500 Subject: [PATCH 02/17] Wrap text in block quotes --- src/app.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app.css b/src/app.css index cf8680e..eba8caf 100644 --- a/src/app.css +++ b/src/app.css @@ -340,6 +340,12 @@ @apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg; } + .literalblock { + pre { + @apply text-wrap; + } + } + table { @apply w-full overflow-x-auto; From e35bfd75a74ef6f3fda8a792053a60fc66130b29 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 00:07:24 -0500 Subject: [PATCH 03/17] Gracefully handle missing events when loading a publication --- src/lib/components/Publication.svelte | 25 ++++++--- src/lib/components/PublicationSection.svelte | 24 +++++++-- src/lib/data_structures/lazy.ts | 20 ++++++- src/lib/data_structures/publication_tree.ts | 55 ++++++++++++++------ 4 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 58f29db..1995b67 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -1,5 +1,6 @@ + + + {#if showTocButton && !showToc}
{#each leaves as leaf, i} - setLastElementRef(el, i)} - /> + {#if leaf == null} + + + Error loading content. One or more events could not be loaded. + + {:else} + setLastElementRef(el, i)} + /> + {/if} {/each}
diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index 5eb4f24..6b96660 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -23,24 +23,38 @@ let leafEvent: Promise = $derived.by(async () => await publicationTree.getEvent(address)); + let rootEvent: Promise = $derived.by(async () => await publicationTree.getEvent(rootAddress)); + let publicationType: Promise = $derived.by(async () => (await rootEvent)?.getMatchingTags('type')[0]?.[1]); + let leafHierarchy: Promise = $derived.by(async () => await publicationTree.getHierarchy(address)); + let leafTitle: Promise = $derived.by(async () => (await leafEvent)?.getMatchingTags('title')[0]?.[1]); + let leafContent: Promise = $derived.by(async () => asciidoctor.convert((await leafEvent)?.content ?? '')); let previousLeafEvent: NDKEvent | null = $derived.by(() => { - const index = leaves.findIndex(leaf => leaf.tagAddress() === address); - if (index === 0) { - return null; - } - return leaves[index - 1]; + let index: number; + let event: NDKEvent | null = null; + let decrement = 1; + + do { + index = leaves.findIndex(leaf => leaf?.tagAddress() === address); + if (index === 0) { + return null; + } + event = leaves[index - decrement++]; + } while (event == null && index - decrement >= 0); + + return event; }); + let previousLeafHierarchy: Promise = $derived.by(async () => { if (!previousLeafEvent) { return null; diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts index 6be32fb..1589cba 100644 --- a/src/lib/data_structures/lazy.ts +++ b/src/lib/data_structures/lazy.ts @@ -1,16 +1,32 @@ +export enum LazyStatus { + Pending, + Resolved, + Error, +} + export class Lazy { #value?: T; #resolver: () => Promise; + status: LazyStatus; + constructor(resolver: () => Promise) { this.#resolver = resolver; + this.status = LazyStatus.Pending; } - async value(): Promise { + async value(): Promise { if (!this.#value) { - this.#value = await this.#resolver(); + try { + this.#value = await this.#resolver(); + } catch (error) { + this.status = LazyStatus.Error; + console.error(error); + return null; + } } + this.status = LazyStatus.Resolved; return this.#value; } } \ No newline at end of file diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 466e676..d703555 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -8,14 +8,20 @@ enum PublicationTreeNodeType { Leaf, } +enum PublicationTreeNodeStatus { + Resolved, + Error, +} + interface PublicationTreeNode { type: PublicationTreeNodeType; + status: PublicationTreeNodeStatus; address: string; parent?: PublicationTreeNode; children?: Array>; } -export class PublicationTree implements AsyncIterable { +export class PublicationTree implements AsyncIterable { /** * The root node of the tree. */ @@ -50,6 +56,7 @@ export class PublicationTree implements AsyncIterable { const rootAddress = rootEvent.tagAddress(); this.#root = { type: this.#getNodeType(rootEvent), + status: PublicationTreeNodeStatus.Resolved, address: rootAddress, children: [], }; @@ -84,6 +91,7 @@ export class PublicationTree implements AsyncIterable { const node: PublicationTreeNode = { type: await this.#getNodeType(event), + status: PublicationTreeNodeStatus.Resolved, address, parent: parentNode, children: [], @@ -134,7 +142,7 @@ export class PublicationTree implements AsyncIterable { * @param address The address of the parent node. * @returns An array of addresses of any loaded child nodes. */ - async getChildAddresses(address: string): Promise { + 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.`); @@ -142,7 +150,7 @@ export class PublicationTree implements AsyncIterable { return Promise.all( node.children?.map(async child => - (await child.value()).address + (await child.value())?.address ?? null ) ?? [] ); } @@ -205,20 +213,26 @@ export class PublicationTree implements AsyncIterable { async tryMoveToFirstChild(): Promise { if (!this.target) { - throw new Error("Cursor: Target node is null or undefined."); + console.debug("Cursor: Target node is null or undefined."); + return false; } if (this.target.type === PublicationTreeNodeType.Leaf) { return false; } + + if (this.target.children == null || this.target.children.length === 0) { + return false; + } - this.target = (await this.target.children?.at(0)?.value())!; + this.target = await this.target.children?.at(0)?.value(); return true; } async tryMoveToNextSibling(): Promise { if (!this.target) { - throw new Error("Cursor: Target node is null or undefined."); + console.debug("Cursor: Target node is null or undefined."); + return false; } const parent = this.target.parent; @@ -228,25 +242,27 @@ export class PublicationTree implements AsyncIterable { } const currentIndex = await siblings.findIndexAsync( - async (sibling: Lazy) => (await sibling.value()).address === this.target!.address + async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address ); if (currentIndex === -1) { return false; } - const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; - if (!nextSibling) { + if (currentIndex + 1 >= siblings.length) { return false; } + const nextSibling = (await siblings.at(currentIndex + 1)?.value()); this.target = nextSibling; + return true; } tryMoveToParent(): boolean { if (!this.target) { - throw new Error("Cursor: Target node is null or undefined."); + console.debug("Cursor: Target node is null or undefined."); + return false; } const parent = this.target.parent; @@ -263,11 +279,11 @@ export class PublicationTree implements AsyncIterable { // #region Async Iterator Implementation - [Symbol.asyncIterator](): AsyncIterator { + [Symbol.asyncIterator](): AsyncIterator { return this; } - async next(): Promise> { + async next(): Promise> { if (!this.#cursor.target) { await this.#cursor.tryMoveTo(this.#bookmark); } @@ -297,7 +313,7 @@ export class PublicationTree implements AsyncIterable { } while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf); const event = await this.getEvent(this.#cursor.target!.address); - return { done: false, value: event! }; + return { done: false, value: event }; } // #endregion @@ -396,9 +412,17 @@ export class PublicationTree implements AsyncIterable { }); if (!event) { - throw new Error( + console.debug( `PublicationTree: Event with address ${address} not found.` ); + + return { + type: PublicationTreeNodeType.Leaf, + status: PublicationTreeNodeStatus.Error, + address, + parent: parentNode, + children: [], + }; } this.#events.set(address, event); @@ -406,7 +430,8 @@ export class PublicationTree implements AsyncIterable { const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const node: PublicationTreeNode = { - type: await this.#getNodeType(event), + type: this.#getNodeType(event), + status: PublicationTreeNodeStatus.Resolved, address, parent: parentNode, children: [], From 557f3c0dfb9911147acbc59a4af81996dfe46bce Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 09:25:57 -0500 Subject: [PATCH 04/17] Add `content-visibility-auto` Tailwind utility --- src/lib/components/PublicationSection.svelte | 2 +- tailwind.config.cjs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index 6b96660..ae3170b 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -105,7 +105,7 @@ -
+
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 380981b..d951507 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,4 +1,5 @@ import flowbite from "flowbite/plugin"; +import plugin from "tailwindcss/plugin"; /** @type {import('tailwindcss').Config}*/ const config = { @@ -85,6 +86,25 @@ const config = { plugins: [ flowbite(), + plugin(function({ addUtilities, matchUtilities }) { + addUtilities({ + '.content-visibility-auto': { + 'content-visibility': 'auto', + }, + '.contain-size': { + contain: 'size', + }, + }); + + matchUtilities({ + 'contain-intrinsic-w-*': value => ({ + width: value, + }), + 'contain-intrinsic-h-*': value => ({ + height: value, + }) + }); + }) ], darkMode: 'class', From e9aa513d5fa58297978bc2f7d797b092745df4ac Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 09:27:35 -0500 Subject: [PATCH 05/17] Load only one element at a time on scroll --- src/lib/components/Publication.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 1995b67..968ad2a 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -138,7 +138,7 @@ observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !isLoading) { - loadMore(4); + loadMore(1); } }); }, { threshold: 0.5 }); From 74d6b306b9bacce853c96639c2e49d5342cddbd2 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 09:27:54 -0500 Subject: [PATCH 06/17] Track loaded addresses in a publication --- src/lib/components/Publication.svelte | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 968ad2a..ffb2f28 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -30,6 +30,7 @@ // TODO: Test load handling. let leaves = $state([]); + let loadedAddresses = $state>(new Set()); let isLoading = $state(false); let lastElementRef = $state(null); @@ -40,10 +41,21 @@ for (let i = 0; i < count; i++) { const nextItem = await publicationTree.next(); + + const nextAddress = nextItem.value?.tagAddress(); + if (nextAddress && loadedAddresses.has(nextAddress)) { + continue; + } + + if (nextAddress && !loadedAddresses.has(nextAddress)) { + loadedAddresses.add(nextAddress); + } + if (leaves.includes(nextItem.value) || (nextItem.done && nextItem.value === null)) { isLoading = false; return; } + leaves.push(nextItem.value); } From b9925813dbfa0e0305a08ffd41a651ffb72b011f Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 21:40:25 -0500 Subject: [PATCH 07/17] Remove completed TODOs --- src/lib/components/PublicationSection.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index ae3170b..7e14a97 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -104,12 +104,10 @@ }); -
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} - {#each divergingBranches as [branch, depth]} {@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} {/each} From f72adcb6d3be5f6ffbc7fa5d92b6a2734c122199 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 6 May 2025 21:48:53 -0500 Subject: [PATCH 08/17] Display a "Load More" button if load-on-scroll fails --- src/lib/components/Publication.svelte | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index ffb2f28..eaf3559 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -167,8 +167,8 @@ + - {#if showTocButton && !showToc} +
+ {#if isLoading} + + {:else} + + {/if} +