4 changed files with 435 additions and 201 deletions
@ -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 @@ |
|||||||
|
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