From b3adae4dfa9e1f5b3cc3923da2a3989fa3f5cdd5 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 29 Feb 2024 04:37:26 +0000 Subject: [PATCH] refactor: threads this was done largely to identify bug resolved in ef3bcace93c9e39cc40c784ff6e69e96cca3be15 --- src/lib/wrappers/Thread.svelte | 93 ++-------- src/lib/wrappers/ThreadTree.svelte | 242 ++++++++++++++------------- src/lib/wrappers/thread_tree.spec.ts | 231 +++++++++++++++++++++++++ src/lib/wrappers/thread_tree.ts | 70 ++++++++ 4 files changed, 435 insertions(+), 201 deletions(-) create mode 100644 src/lib/wrappers/thread_tree.spec.ts create mode 100644 src/lib/wrappers/thread_tree.ts 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 [] +}