diff --git a/src/lib/wrappers/Thread.svelte b/src/lib/wrappers/Thread.svelte
index 4d8abf2..6c83779 100644
--- a/src/lib/wrappers/Thread.svelte
+++ b/src/lib/wrappers/Thread.svelte
@@ -1,8 +1,7 @@
-{#if type === 'issue' && thread_tree_root}
-
-{/if}
-{#if thread_revision_trees}
- {#each thread_revision_trees as tree, i}
- {#if i > 0}
-
new revision
- {/if}
-
- {/each}
-{/if}
+{#each thread_trees as tree, i}
+ {#if i > 0}
+ new revision
+ {/if}
+
+{/each}
diff --git a/src/lib/wrappers/ThreadTree.svelte b/src/lib/wrappers/ThreadTree.svelte
index a206e83..9789e23 100644
--- a/src/lib/wrappers/ThreadTree.svelte
+++ b/src/lib/wrappers/ThreadTree.svelte
@@ -4,7 +4,7 @@
import type { ThreadTreeNode } from '$lib/components/events/type'
import ComposeReply from './ComposeReply.svelte'
- export let tree: ThreadTreeNode
+ export let tree: ThreadTreeNode | undefined
export let type: 'proposal' | 'issue' = 'proposal'
export let show_compose = false
const countReplies = (tree: ThreadTreeNode, starting: number = 0): number => {
@@ -15,122 +15,124 @@
}
-
-
- {#each tree.child_nodes as layer1}
-
-
- {#each layer1.child_nodes as layer2}
-
-
- {#each layer2.child_nodes as layer3}
-
-
- {#each layer3.child_nodes as layer4}
-
-
- {#each layer4.child_nodes as layer5}
-
-
- {#each layer5.child_nodes as layer6}
-
-
- {#each layer6.child_nodes as layer7}
-
-
- {#each layer7.child_nodes as layer8}
-
-
- {#each layer8.child_nodes as layer9}
-
-
- {#each layer9.child_nodes as layer10}
-
-
- {#each layer10.child_nodes as layer11}
-
-
- {#each layer11.child_nodes as layer12}
-
-
- {#each layer12.child_nodes as layer13}
-
-
- {#each layer13.child_nodes as layer14}
-
-
- {#each layer14.child_nodes as layer15}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
-
- {/each}
- {#if show_compose}
-
- {/if}
-
+{#if tree}
+
+
+ {#each tree.child_nodes as layer1}
+
+
+ {#each layer1.child_nodes as layer2}
+
+
+ {#each layer2.child_nodes as layer3}
+
+
+ {#each layer3.child_nodes as layer4}
+
+
+ {#each layer4.child_nodes as layer5}
+
+
+ {#each layer5.child_nodes as layer6}
+
+
+ {#each layer6.child_nodes as layer7}
+
+
+ {#each layer7.child_nodes as layer8}
+
+
+ {#each layer8.child_nodes as layer9}
+
+
+ {#each layer9.child_nodes as layer10}
+
+
+ {#each layer10.child_nodes as layer11}
+
+
+ {#each layer11.child_nodes as layer12}
+
+
+ {#each layer12.child_nodes as layer13}
+
+
+ {#each layer13.child_nodes as layer14}
+
+
+ {#each layer14.child_nodes as layer15}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {/each}
+ {#if show_compose}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/wrappers/thread_tree.spec.ts b/src/lib/wrappers/thread_tree.spec.ts
new file mode 100644
index 0000000..9a8958a
--- /dev/null
+++ b/src/lib/wrappers/thread_tree.spec.ts
@@ -0,0 +1,231 @@
+import { describe, expect, test } from 'vitest'
+import { createThreadTree, getParentId, getThreadTrees } from './thread_tree'
+import NDK, {
+ NDKEvent,
+ NDKPrivateKeySigner,
+ type NDKTag,
+} from '@nostr-dev-kit/ndk'
+import { reply_kind } from '$lib/kinds'
+
+const ndk = new NDK()
+ndk.signer = new NDKPrivateKeySigner(
+ '08608a436aee4c07ea5c36f85cb17c58f52b3ad7094f9318cc777771f0bf218b'
+)
+const generateEventWithTags = async (tags: NDKTag[]): Promise => {
+ const event = new NDKEvent(ndk)
+ event.kind = reply_kind
+ event.content = Math.random().toFixed(10)
+ tags.forEach((tag) => {
+ event.tags.push(tag)
+ })
+ await event.sign()
+ return event
+}
+
+describe('getParentId', () => {
+ describe('when all types of e tag are present', () => {
+ test('returns id of e reply tag', async () => {
+ expect(
+ getParentId(
+ await generateEventWithTags([
+ ['e', '012'],
+ ['e', '123', '', 'root'],
+ ['e', '789', '', 'mention'],
+ ['e', '456', '', 'reply'],
+ ])
+ )
+ ).toEqual('456')
+ })
+ })
+ describe('when all types of e tag are present except reply', () => {
+ test('returns id of e root tag', async () => {
+ expect(
+ getParentId(
+ await generateEventWithTags([
+ ['e', '012'],
+ ['e', '123', '', 'root'],
+ ['e', '789', '', 'mention'],
+ ])
+ )
+ ).toEqual('123')
+ })
+ })
+ describe('when only mention and unmarked e tags are present', () => {
+ test('returns id of unmarked e tag', async () => {
+ expect(
+ getParentId(
+ await generateEventWithTags([
+ ['e', '012'],
+ ['e', '789', '', 'mention'],
+ ])
+ )
+ ).toEqual('012')
+ })
+ })
+ describe('when only mention e tag are present', () => {
+ test('return undefined', async () => {
+ expect(
+ getParentId(await generateEventWithTags([['e', '789', '', 'mention']]))
+ ).toBeUndefined()
+ })
+ })
+})
+
+describe('createThreadTree', () => {
+ describe('only events without parents are returned as top level array items', () => {
+ describe('1 parent, 1 child', () => {
+ test('returns array with only parent at top level', async () => {
+ const root = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const tree = createThreadTree([root, reply_to_root])
+ expect(tree).to.have.length(1)
+ expect(tree[0].event.id).to.eq(root.id)
+ })
+ test('parent has child in child_nodes, child has empty child nodes', async () => {
+ const root = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const tree = createThreadTree([root, reply_to_root])
+ expect(tree[0].child_nodes).to.have.length(1)
+ expect(tree[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(tree[0].child_nodes[0].child_nodes).to.be.length(0)
+ })
+ })
+ describe('1 grand parent, 1 parent, 1 child - out of order', () => {
+ test('returns array with only grand parent at top level with parent as its child, and child as parents child', async () => {
+ const grand_parent = await generateEventWithTags([])
+ const parent = await generateEventWithTags([
+ ['e', grand_parent.id, '', 'reply'],
+ ])
+ const child = await generateEventWithTags([
+ ['e', parent.id, '', 'reply'],
+ ])
+ const tree = createThreadTree([grand_parent, child, parent])
+ expect(tree).to.have.length(1)
+ expect(tree[0].event.id).to.eq(grand_parent.id)
+ expect(tree[0].child_nodes).to.have.length(1)
+ expect(tree[0].child_nodes[0].event.id).to.eq(parent.id)
+ expect(tree[0].child_nodes[0].child_nodes).to.have.length(1)
+ expect(tree[0].child_nodes[0].child_nodes[0].event.id).to.eq(child.id)
+ expect(
+ tree[0].child_nodes[0].child_nodes[0].child_nodes
+ ).to.have.length(0)
+ })
+ })
+ describe('2 roots, 1 child', () => {
+ test('returns array with 2 roots at top level', async () => {
+ const root = await generateEventWithTags([])
+ const root2 = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const tree = createThreadTree([root, reply_to_root, root2])
+ expect(tree).to.have.length(2)
+ expect(tree[0].event.id).to.eq(root.id)
+ expect(tree[1].event.id).to.eq(root2.id)
+ expect(tree[1].child_nodes).to.have.length(0)
+ expect(tree[0].child_nodes).to.have.length(1)
+ expect(tree[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(tree[0].child_nodes[0].child_nodes).to.be.length(0)
+ })
+ })
+ })
+})
+
+describe('getThreadTrees', () => {
+ describe('issue', () => {
+ describe('2 roots, 1 child', () => {
+ test('array only contains node related to specified event and children', async () => {
+ const root = await generateEventWithTags([])
+ const root2 = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const trees = getThreadTrees('issue', root, [
+ root,
+ reply_to_root,
+ root2,
+ ])
+ expect(trees).to.have.length(1)
+ expect(trees[0].event.id).to.eq(root.id)
+ expect(trees[0].child_nodes).to.have.length(1)
+ expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(trees[0].child_nodes[0].child_nodes).to.be.length(0)
+ })
+ })
+ })
+ describe('proposal', () => {
+ describe('2 roots, 1 child', () => {
+ test('array only contains node related to specified event and children', async () => {
+ const root = await generateEventWithTags([])
+ const root2 = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const trees = getThreadTrees('proposal', root, [
+ root,
+ reply_to_root,
+ root2,
+ ])
+ expect(trees).to.have.length(1)
+ expect(trees[0].event.id).to.eq(root.id)
+ expect(trees[0].child_nodes).to.have.length(1)
+ expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(trees[0].child_nodes[0].child_nodes).to.be.length(0)
+ })
+ })
+ describe('2 roots, 1 reply, 1 revision', () => {
+ test('array contains node related to specified event with reply, and revision', async () => {
+ const root = await generateEventWithTags([])
+ const root2 = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const revision_of_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ['t', 'revision-root'],
+ ])
+ const trees = getThreadTrees('proposal', root, [
+ root,
+ reply_to_root,
+ root2,
+ revision_of_root,
+ ])
+ expect(trees).to.have.length(2)
+ expect(trees[0].event.id).to.eq(root.id)
+ expect(trees[0].child_nodes).to.have.length(1)
+ expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(trees[1].event.id).to.eq(revision_of_root.id)
+ })
+ })
+ })
+ describe('issue', () => {
+ describe('2 roots, 1 reply, 1 revision', () => {
+ test('array contains only node related to specified event with reply and revision as children', async () => {
+ const root = await generateEventWithTags([])
+ const root2 = await generateEventWithTags([])
+ const reply_to_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ])
+ const revision_of_root = await generateEventWithTags([
+ ['e', root.id, '', 'reply'],
+ ['t', 'revision-root'],
+ ])
+ const trees = getThreadTrees('issue', root, [
+ root,
+ reply_to_root,
+ root2,
+ revision_of_root,
+ ])
+ expect(trees).to.have.length(1)
+ expect(trees[0].event.id).to.eq(root.id)
+ expect(trees[0].child_nodes).to.have.length(2)
+ expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
+ expect(trees[0].child_nodes[1].event.id).to.eq(revision_of_root.id)
+ })
+ })
+ })
+})
diff --git a/src/lib/wrappers/thread_tree.ts b/src/lib/wrappers/thread_tree.ts
new file mode 100644
index 0000000..c2934b5
--- /dev/null
+++ b/src/lib/wrappers/thread_tree.ts
@@ -0,0 +1,70 @@
+import type { ThreadTreeNode } from '$lib/components/events/type'
+import type { NDKEvent } from '@nostr-dev-kit/ndk'
+
+export const getParentId = (reply: NDKEvent): string | undefined => {
+ const t =
+ reply.tags.find((tag) => tag.length === 4 && tag[3] === 'reply') ||
+ reply.tags.find((tag) => tag.length === 4 && tag[3] === 'root') ||
+ // include events that don't use nip 10 markers
+ reply.tags.find((tag) => tag.length < 4 && tag[0] === 'e')
+ return t ? t[1] : undefined
+}
+
+export const createThreadTree = (replies: NDKEvent[]): ThreadTreeNode[] => {
+ const hashTable: { [key: string]: ThreadTreeNode } = Object.create(null)
+ replies.forEach(
+ (reply) => (hashTable[reply.id] = { event: reply, child_nodes: [] })
+ )
+ const thread_tree: ThreadTreeNode[] = []
+ replies.forEach((reply) => {
+ const reply_parent_id = getParentId(reply)
+ if (reply_parent_id && hashTable[reply_parent_id]) {
+ hashTable[reply_parent_id].child_nodes.push(hashTable[reply.id])
+ hashTable[reply_parent_id].child_nodes.sort(
+ (a, b) => (a.event.created_at || 0) - (b.event.created_at || 0)
+ )
+ } else thread_tree.push(hashTable[reply.id])
+ })
+ return thread_tree
+}
+
+export const splitIntoRevisionThreadTrees = (
+ tree: ThreadTreeNode
+): ThreadTreeNode[] => {
+ const thread_revision_trees: ThreadTreeNode[] = [
+ {
+ ...tree,
+ child_nodes: [...tree?.child_nodes],
+ },
+ ]
+ thread_revision_trees[0].child_nodes = [
+ ...thread_revision_trees[0].child_nodes.filter((n) => {
+ if (n.event.tags.some((t) => t.length > 1 && t[1] === 'revision-root')) {
+ thread_revision_trees.push(n)
+ return false
+ }
+ return true
+ }),
+ ]
+ return thread_revision_trees.sort(
+ (a, b) => (a.event.created_at || 0) - (b.event.created_at || 0)
+ )
+}
+
+export const getThreadTrees = (
+ type: 'proposal' | 'issue',
+ event: NDKEvent | undefined,
+ replies: NDKEvent[] | undefined
+): ThreadTreeNode[] => {
+ if (event) {
+ const all_trees = createThreadTree(replies ? [event, ...replies] : [event])
+ const event_tree = all_trees.find((t) => t.event.id === event.id)
+ if (event_tree) {
+ // TODO: add 'mentions' and secondary references with a 'metioned event wrapper'
+ if (type === 'proposal') return splitIntoRevisionThreadTrees(event_tree)
+ return [event_tree]
+ }
+ }
+
+ return []
+}