Browse Source

refactor: threads

this was done largely to identify bug resolved in
ef3bcace93
master
DanConwayDev 2 years ago
parent
commit
b3adae4dfa
No known key found for this signature in database
GPG Key ID: 68E15486D73F75E1
  1. 93
      src/lib/wrappers/Thread.svelte
  2. 242
      src/lib/wrappers/ThreadTree.svelte
  3. 231
      src/lib/wrappers/thread_tree.spec.ts
  4. 70
      src/lib/wrappers/thread_tree.ts

93
src/lib/wrappers/Thread.svelte

@ -1,8 +1,7 @@ @@ -1,8 +1,7 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import { writable } from 'svelte/store'
import type { ThreadTreeNode } from '$lib/components/events/type'
import ThreadTree from './ThreadTree.svelte'
import { getThreadTrees } from './thread_tree'
export let event: NDKEvent
export let type: 'proposal' | 'issue' = 'proposal'
@ -10,84 +9,16 @@ @@ -10,84 +9,16 @@
export let replies: NDKEvent[] | undefined = undefined
const getParentId = (reply: NDKEvent): string | undefined => {
let 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
}
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) => {
let 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
}
const splitIntoRevisionThreadTrees = (
tree: ThreadTreeNode
): ThreadTreeNode[] => {
let 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)
)
}
let thread_tree_store = writable(
createThreadTree(replies ? [event, ...replies] : [event])
)
let thread_tree_root: ThreadTreeNode | undefined
let thread_revision_trees: ThreadTreeNode[] | undefined
// TODO: add 'mentions' and secondary references that fall outside of root childNodes
// they should appear in the UI as 'mentioned in' and be clear that replies ar enot incldued
$: {
if (replies) thread_tree_store.set(createThreadTree([event, ...replies]))
thread_tree_root = $thread_tree_store.find((t) => t.event.id === event.id)
if (type === 'proposal' && thread_tree_root)
thread_revision_trees = splitIntoRevisionThreadTrees(thread_tree_root)
}
$: thread_trees = getThreadTrees(type, event, replies)
</script>
{#if type === 'issue' && thread_tree_root}
<ThreadTree {type} tree={thread_tree_root} {show_compose} />
{/if}
{#if thread_revision_trees}
{#each thread_revision_trees as tree, i}
{#if i > 0}
<div class="divider">new revision</div>
{/if}
<ThreadTree
{type}
{tree}
show_compose={show_compose && thread_revision_trees.length - 1 === i}
/>
{/each}
{/if}
{#each thread_trees as tree, i}
{#if i > 0}
<div class="divider">new revision</div>
{/if}
<ThreadTree
{type}
{tree}
show_compose={show_compose && thread_trees.length - 1 === i}
/>
{/each}

242
src/lib/wrappers/ThreadTree.svelte

@ -4,7 +4,7 @@ @@ -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 @@ @@ -15,122 +15,124 @@
}
</script>
<EventCard {type} event={tree.event} />
<ThreadWrapper num_replies={countReplies(tree)}>
{#each tree.child_nodes as layer1}
<EventCard {type} event={layer1.event} />
<ThreadWrapper num_replies={countReplies(layer1)}>
{#each layer1.child_nodes as layer2}
<EventCard {type} event={layer2.event} />
<ThreadWrapper num_replies={countReplies(layer2)}>
{#each layer2.child_nodes as layer3}
<EventCard {type} event={layer3.event} />
<ThreadWrapper num_replies={countReplies(layer3)}>
{#each layer3.child_nodes as layer4}
<EventCard {type} event={layer4.event} />
<ThreadWrapper num_replies={countReplies(layer4)}>
{#each layer4.child_nodes as layer5}
<EventCard {type} event={layer5.event} />
<ThreadWrapper num_replies={countReplies(layer5)}>
{#each layer5.child_nodes as layer6}
<EventCard {type} event={layer6.event} />
<ThreadWrapper num_replies={countReplies(layer6)}>
{#each layer6.child_nodes as layer7}
<EventCard {type} event={layer7.event} />
<ThreadWrapper num_replies={countReplies(layer7)}>
{#each layer7.child_nodes as layer8}
<EventCard {type} event={layer8.event} />
<ThreadWrapper
num_replies={countReplies(layer8)}
>
{#each layer8.child_nodes as layer9}
<EventCard {type} event={layer9.event} />
<ThreadWrapper
num_replies={countReplies(layer9)}
>
{#each layer9.child_nodes as layer10}
<EventCard
{type}
event={layer10.event}
/>
<ThreadWrapper
num_replies={countReplies(layer10)}
>
{#each layer10.child_nodes as layer11}
<EventCard
{type}
event={layer11.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer11
)}
>
{#each layer11.child_nodes as layer12}
<EventCard
{type}
event={layer12.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer12
)}
>
{#each layer12.child_nodes as layer13}
<EventCard
{type}
event={layer13.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer13
)}
>
{#each layer13.child_nodes as layer14}
<EventCard
{type}
event={layer14.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer14
)}
>
{#each layer14.child_nodes as layer15}
<EventCard
{type}
event={layer15.event}
/>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
{#if show_compose}
<ComposeReply {type} event={tree.event} />
{/if}
</ThreadWrapper>
{#if tree}
<EventCard {type} event={tree.event} />
<ThreadWrapper num_replies={countReplies(tree)}>
{#each tree.child_nodes as layer1}
<EventCard {type} event={layer1.event} />
<ThreadWrapper num_replies={countReplies(layer1)}>
{#each layer1.child_nodes as layer2}
<EventCard {type} event={layer2.event} />
<ThreadWrapper num_replies={countReplies(layer2)}>
{#each layer2.child_nodes as layer3}
<EventCard {type} event={layer3.event} />
<ThreadWrapper num_replies={countReplies(layer3)}>
{#each layer3.child_nodes as layer4}
<EventCard {type} event={layer4.event} />
<ThreadWrapper num_replies={countReplies(layer4)}>
{#each layer4.child_nodes as layer5}
<EventCard {type} event={layer5.event} />
<ThreadWrapper num_replies={countReplies(layer5)}>
{#each layer5.child_nodes as layer6}
<EventCard {type} event={layer6.event} />
<ThreadWrapper num_replies={countReplies(layer6)}>
{#each layer6.child_nodes as layer7}
<EventCard {type} event={layer7.event} />
<ThreadWrapper num_replies={countReplies(layer7)}>
{#each layer7.child_nodes as layer8}
<EventCard {type} event={layer8.event} />
<ThreadWrapper
num_replies={countReplies(layer8)}
>
{#each layer8.child_nodes as layer9}
<EventCard {type} event={layer9.event} />
<ThreadWrapper
num_replies={countReplies(layer9)}
>
{#each layer9.child_nodes as layer10}
<EventCard
{type}
event={layer10.event}
/>
<ThreadWrapper
num_replies={countReplies(layer10)}
>
{#each layer10.child_nodes as layer11}
<EventCard
{type}
event={layer11.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer11
)}
>
{#each layer11.child_nodes as layer12}
<EventCard
{type}
event={layer12.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer12
)}
>
{#each layer12.child_nodes as layer13}
<EventCard
{type}
event={layer13.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer13
)}
>
{#each layer13.child_nodes as layer14}
<EventCard
{type}
event={layer14.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer14
)}
>
{#each layer14.child_nodes as layer15}
<EventCard
{type}
event={layer15.event}
/>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
{#if show_compose}
<ComposeReply {type} event={tree.event} />
{/if}
</ThreadWrapper>
{/if}

231
src/lib/wrappers/thread_tree.spec.ts

@ -0,0 +1,231 @@ @@ -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<NDKEvent> => {
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)
})
})
})
})

70
src/lib/wrappers/thread_tree.ts

@ -0,0 +1,70 @@ @@ -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 []
}
Loading…
Cancel
Save