4 changed files with 435 additions and 201 deletions
@ -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) |
||||
}) |
||||
}) |
||||
}) |
||||
}) |
||||
@ -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…
Reference in new issue