Browse Source

Merges pull request #32

New Publication Loader Continued
master
silberengel 10 months ago
parent
commit
31e6d0984d
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 6
      src/app.css
  2. 64
      src/lib/components/Publication.svelte
  3. 30
      src/lib/components/PublicationSection.svelte
  4. 49
      src/lib/data_structures/lazy.ts
  5. 176
      src/lib/data_structures/publication_tree.ts
  6. 20
      tailwind.config.cjs

6
src/app.css

@ -340,6 +340,12 @@
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg; @apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
} }
.literalblock {
pre {
@apply text-wrap;
}
}
table { table {
@apply w-full overflow-x-auto; @apply w-full overflow-x-auto;

64
src/lib/components/Publication.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
Alert,
Button, Button,
Sidebar, Sidebar,
SidebarGroup, SidebarGroup,
@ -10,7 +11,7 @@
Tooltip, Tooltip,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { BookOutline } from "flowbite-svelte-icons"; import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state"; import { page } from "$app/state";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte"; import PublicationSection from "./PublicationSection.svelte";
@ -28,8 +29,9 @@
// TODO: Test load handling. // TODO: Test load handling.
let leaves = $state<NDKEvent[]>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false); let isLoading = $state<boolean>(false);
let isDone = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null); let lastElementRef = $state<HTMLElement | null>(null);
let observer: IntersectionObserver; let observer: IntersectionObserver;
@ -38,12 +40,15 @@
isLoading = true; isLoading = true;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const nextItem = await publicationTree.next(); const iterResult = await publicationTree.next();
if (leaves.includes(nextItem.value) || nextItem.done) { const { done, value } = iterResult;
isLoading = false;
return; if (done) {
isDone = true;
break;
} }
leaves.push(nextItem.value);
leaves.push(value);
} }
isLoading = false; isLoading = false;
@ -60,8 +65,13 @@
return; return;
} }
observer.observe(lastElementRef!); if (isDone) {
return () => observer.unobserve(lastElementRef!); observer?.unobserve(lastElementRef!);
return;
}
observer?.observe(lastElementRef!);
return () => observer?.unobserve(lastElementRef!);
}); });
// #endregion // #endregion
@ -136,8 +146,8 @@
// Set up the intersection observer. // Set up the intersection observer.
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading) { if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(4); loadMore(1);
} }
}); });
}, { threshold: 0.5 }); }, { threshold: 0.5 });
@ -153,6 +163,8 @@
}); });
</script> </script>
<!-- TODO: Keep track of already-loaded leaves. -->
<!-- TODO: Handle entering mid-document and scrolling up. -->
{#if showTocButton && !showToc} {#if showTocButton && !showToc}
<!-- <Button <!-- <Button
@ -185,13 +197,31 @@
{/if} --> {/if} -->
<div class="flex flex-col space-y-4 max-w-2xl"> <div class="flex flex-col space-y-4 max-w-2xl">
{#each leaves as leaf, i} {#each leaves as leaf, i}
<PublicationSection {#if leaf == null}
rootAddress={rootAddress} <Alert class='flex space-x-2'>
leaves={leaves} <ExclamationCircleOutline class='w-5 h-5' />
address={leaf.tagAddress()} Error loading content. One or more events could not be loaded.
ref={(el) => setLastElementRef(el, i)} </Alert>
/> {:else}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each} {/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">
Loading...
</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>
Show More
</Button>
{/if}
</div>
</div> </div>
<style> <style>

30
src/lib/components/PublicationSection.svelte

@ -14,7 +14,7 @@
}: { }: {
address: string, address: string,
rootAddress: string, rootAddress: string,
leaves: NDKEvent[], leaves: Array<NDKEvent | null>,
ref: (ref: HTMLElement) => void, ref: (ref: HTMLElement) => void,
} = $props(); } = $props();
@ -23,24 +23,38 @@
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address)); await publicationTree.getEvent(address));
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress)); await publicationTree.getEvent(rootAddress));
let publicationType: Promise<string | undefined> = $derived.by(async () => let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); (await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address)); await publicationTree.getHierarchy(address));
let leafTitle: Promise<string | undefined> = $derived.by(async () => let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); (await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafContent: Promise<string | Document> = $derived.by(async () => let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? '')); asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => { let previousLeafEvent: NDKEvent | null = $derived.by(() => {
const index = leaves.findIndex(leaf => leaf.tagAddress() === address); let index: number;
if (index === 0) { let event: NDKEvent | null = null;
return null; let decrement = 1;
}
return leaves[index - 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<NDKEvent[] | null> = $derived.by(async () => { let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) { if (!previousLeafEvent) {
return null; return null;
@ -90,12 +104,10 @@
}); });
</script> </script>
<!-- TODO: Correctly handle events that are the start of a content section. --> <section bind:this={sectionRef} class='publication-leather content-visibility-auto'>
<section bind:this={sectionRef} class='publication-leather'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' /> <TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- TODO: Ensure we render all headings, not just the first one. -->
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} {@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{/each} {/each}

49
src/lib/data_structures/lazy.ts

@ -1,16 +1,55 @@
export enum LazyStatus {
Pending,
Resolved,
Error,
}
export class Lazy<T> { export class Lazy<T> {
#value?: T; #value: T | null = null;
#resolver: () => Promise<T>; #resolver: () => Promise<T>;
#pendingPromise: Promise<T | null> | null = null;
status: LazyStatus;
constructor(resolver: () => Promise<T>) { constructor(resolver: () => Promise<T>) {
this.#resolver = resolver; this.#resolver = resolver;
this.status = LazyStatus.Pending;
} }
async value(): Promise<T> { /**
if (!this.#value) { * Resolves the lazy object and returns the value.
this.#value = await this.#resolver(); *
* @returns The resolved value.
*
* @remarks Lazy object resolution is performed as an atomic operation. If a resolution has
* already been requested when this function is invoked, the pending promise from the earlier
* invocation is returned. Thus, all calls to this function before it is resolved will depend on
* a single resolution.
*/
value(): Promise<T | null> {
if (this.status === LazyStatus.Resolved) {
return Promise.resolve(this.#value);
}
if (this.#pendingPromise) {
return this.#pendingPromise;
} }
return this.#value; 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 (error) {
this.status = LazyStatus.Error;
console.error(error);
return null;
} finally {
this.#pendingPromise = null;
}
} }
} }

176
src/lib/data_structures/publication_tree.ts

@ -4,19 +4,24 @@ import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts'; import { findIndexAsync as _findIndexAsync } from '../utils.ts';
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Root,
Branch, Branch,
Leaf, Leaf,
} }
enum PublicationTreeNodeStatus {
Resolved,
Error,
}
interface PublicationTreeNode { interface PublicationTreeNode {
type: PublicationTreeNodeType; type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus;
address: string; address: string;
parent?: PublicationTreeNode; parent?: PublicationTreeNode;
children?: Array<Lazy<PublicationTreeNode>>; children?: Array<Lazy<PublicationTreeNode>>;
} }
export class PublicationTree implements AsyncIterable<NDKEvent> { export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/** /**
* The root node of the tree. * The root node of the tree.
*/ */
@ -50,7 +55,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
constructor(rootEvent: NDKEvent, ndk: NDK) { constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress(); const rootAddress = rootEvent.tagAddress();
this.#root = { this.#root = {
type: PublicationTreeNodeType.Root, type: this.#getNodeType(rootEvent),
status: PublicationTreeNodeStatus.Resolved,
address: rootAddress, address: rootAddress,
children: [], children: [],
}; };
@ -85,6 +91,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
type: await this.#getNodeType(event), type: await this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
address, address,
parent: parentNode, parent: parentNode,
children: [], children: [],
@ -113,7 +120,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
); );
} }
await this.#addNode(address, parentNode); this.#addNode(address, parentNode);
} }
/** /**
@ -135,7 +142,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
* @param address The address of the parent node. * @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes. * @returns An array of addresses of any loaded child nodes.
*/ */
async getChildAddresses(address: string): Promise<string[]> { async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value(); const node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`); throw new Error(`PublicationTree: Node with address ${address} not found.`);
@ -143,7 +150,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
return Promise.all( return Promise.all(
node.children?.map(async child => node.children?.map(async child =>
(await child.value()).address (await child.value())?.address ?? null
) ?? [] ) ?? []
); );
} }
@ -206,20 +213,44 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
async tryMoveToFirstChild(): Promise<boolean> { async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) { 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();
return true;
}
async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
return false;
}
if (this.target.type === PublicationTreeNodeType.Leaf) { if (this.target.type === PublicationTreeNodeType.Leaf) {
return false; 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(-1)?.value();
return true; return true;
} }
async tryMoveToNextSibling(): Promise<boolean> { async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) { 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; const parent = this.target.parent;
@ -229,25 +260,53 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
} }
const currentIndex = await siblings.findIndexAsync( const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value()).address === this.target!.address async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
); );
if (currentIndex === -1) { if (currentIndex === -1) {
return false; return false;
} }
const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; if (currentIndex + 1 >= siblings.length) {
if (!nextSibling) {
return false; return false;
} }
this.target = nextSibling; this.target = await siblings.at(currentIndex + 1)?.value();
return true;
}
async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
return false;
}
const parent = this.target.parent;
const siblings = parent?.children;
if (!siblings) {
return false;
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
);
if (currentIndex === -1) {
return false;
}
if (currentIndex <= 0) {
return false;
}
this.target = await siblings.at(currentIndex - 1)?.value();
return true; return true;
} }
tryMoveToParent(): boolean { tryMoveToParent(): boolean {
if (!this.target) { 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; const parent = this.target.parent;
@ -264,35 +323,75 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
// #region Async Iterator Implementation // #region Async Iterator Implementation
[Symbol.asyncIterator](): AsyncIterator<NDKEvent> { [Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> {
return this; return this;
} }
async next(): Promise<IteratorResult<NDKEvent>> { // TODO: Add `previous()` method.
async next(): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) { if (!this.#cursor.target) {
await this.#cursor.tryMoveTo(this.#bookmark); if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
}
} }
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do { do {
if (await this.#cursor.tryMoveToFirstChild()) {
continue;
}
if (await this.#cursor.tryMoveToNextSibling()) { if (await this.#cursor.tryMoveToNextSibling()) {
continue; while (await this.#cursor.tryMoveToFirstChild()) {
continue;
}
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
} }
} while (this.#cursor.tryMoveToParent());
if (this.#cursor.tryMoveToParent()) { if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
continue; return { done: false, value: null };
}
// If we get to this point, we're at the root node (can't move up any more).
return { done: true, value: null };
}
async previous(): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
} }
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do {
if (await this.#cursor.tryMoveToPreviousSibling()) {
while (await this.#cursor.tryMoveToLastChild()) {
continue;
}
if (this.#cursor.target?.type === PublicationTreeNodeType.Root) { if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: true, value: null }; return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
} }
} while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf); } while (this.#cursor.tryMoveToParent());
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address); return { done: true, value: null };
return { done: false, value: event! };
} }
// #endregion // #endregion
@ -391,9 +490,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
}); });
if (!event) { if (!event) {
throw new Error( console.debug(
`PublicationTree: Event with address ${address} not found.` `PublicationTree: Event with address ${address} not found.`
); );
return {
type: PublicationTreeNodeType.Leaf,
status: PublicationTreeNodeStatus.Error,
address,
parent: parentNode,
children: [],
};
} }
this.#events.set(address, event); this.#events.set(address, event);
@ -401,7 +508,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
type: await this.#getNodeType(event), type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
address, address,
parent: parentNode, parent: parentNode,
children: [], children: [],
@ -414,11 +522,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
return node; return node;
} }
async #getNodeType(event: NDKEvent): Promise<PublicationTreeNodeType> { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.tagAddress() === this.#root.address) {
return PublicationTreeNodeType.Root;
}
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) {
return PublicationTreeNodeType.Branch; return PublicationTreeNodeType.Branch;
} }

20
tailwind.config.cjs

@ -1,4 +1,5 @@
import flowbite from "flowbite/plugin"; import flowbite from "flowbite/plugin";
import plugin from "tailwindcss/plugin";
/** @type {import('tailwindcss').Config}*/ /** @type {import('tailwindcss').Config}*/
const config = { const config = {
@ -85,6 +86,25 @@ const config = {
plugins: [ plugins: [
flowbite(), 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', darkMode: 'class',

Loading…
Cancel
Save