From 80161456ab30e6343fafdef80cacdb982a84a87c Mon Sep 17 00:00:00 2001 From: LeoLox <58687994+leo-lox@users.noreply.github.com> Date: Sat, 2 Nov 2024 17:35:46 +0100 Subject: [PATCH] wip: build note tree --- lib/domain_layer/entities/tree_node.dart | 24 ++++ lib/domain_layer/usecases/event_feed.dart | 120 +++++++++++++++++ test/unit_test/event_feed/tree_test.dart | 149 ++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 lib/domain_layer/entities/tree_node.dart create mode 100644 lib/domain_layer/usecases/event_feed.dart create mode 100644 test/unit_test/event_feed/tree_test.dart diff --git a/lib/domain_layer/entities/tree_node.dart b/lib/domain_layer/entities/tree_node.dart new file mode 100644 index 00000000..218acd76 --- /dev/null +++ b/lib/domain_layer/entities/tree_node.dart @@ -0,0 +1,24 @@ +class TreeNode { + T value; + List> children; + + TreeNode(this.value) : children = >[]; + + void addChild(TreeNode child) { + children.add(child); + } + + void printTree([String prefix = '']) { + print('$prefix${value.toString()}'); + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var isLastChild = i == children.length - 1; + child.printTree('$prefix${isLastChild ? '└── ' : '├── '}'); + } + } + + @override + String toString() { + return value.toString(); + } +} diff --git a/lib/domain_layer/usecases/event_feed.dart b/lib/domain_layer/usecases/event_feed.dart new file mode 100644 index 00000000..6984566e --- /dev/null +++ b/lib/domain_layer/usecases/event_feed.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:developer'; + +import '../entities/nostr_note.dart'; +import '../entities/tree_node.dart'; +import '../repositories/note_repository.dart'; +import 'follow.dart'; + +/// +/// idea is to combine multiple streams here into the feed stream +/// the feed stream gets then sorted on the ui in an intervall to prevent huge layout shifts +/// +/// there could be one update stream and one for scrolling +/// +/// + +class EventFeed { + final NoteRepository _noteRepository; + final Follow _follow; + + final String rootNoteFetchId = "event-root"; + final String repliesFetchId = "event-replies"; + + // root streams + final StreamController _rootNoteController = + StreamController(); + Stream get rootNoteStream => _rootNoteController.stream; + + final StreamController _replyNotesController = + StreamController(); + Stream get replyNotesStream => _replyNotesController.stream; + + EventFeed(this._noteRepository, this._follow); + + Future subscribeToReplyNotes({ + required String rootNoteId, + required int since, + }) async { + final replyNotes = _noteRepository.subscribeReplyNotes( + requestId: repliesFetchId, + rootNoteId: rootNoteId, + ); + + replyNotes.listen((event) { + _replyNotesController.add(event); + }); + } + + Future subscribeToRootNote({ + required String noteId, + }) async { + final rootNote = _noteRepository.getTextNote( + noteId, + ); + + rootNote.listen((event) { + _rootNoteController.add(event); + }); + } + + /// build a tree from the replies \ + /// [returns] a list of first level replies \ + /// the cildren are replies of replies + + static List> buildRepliesTree({ + required String rootNoteId, + required List replies, + }) { + final List workingList = List.from(replies, growable: true); + workingList.sort((a, b) => a.created_at.compareTo(b.created_at)); + final List> tree = []; + + // find top level replies + for (var i = 0; i < workingList.length; i++) { + final reply = workingList[i]; + + if (reply.getDirectReply?.value == rootNoteId) { + tree.add(TreeNode(reply)); + workingList.remove(reply); + i--; // Adjust index after removal + } + } + + // build the tree + for (final node in tree) { + _buildSubtree(workingList: workingList, parent: node); + } + + return tree; + } + + /// recursive function to build the tree + /// + static _buildSubtree({ + required List workingList, + required TreeNode parent, + }) { + for (var i = 0; i < workingList.length; i++) { + final reply = workingList[i]; + + if (reply.getDirectReply?.value == parent.value.id) { + final child = TreeNode(reply); + parent.addChild(child); + workingList.remove(reply); + i--; // Adjust index after removal + _buildSubtree(workingList: workingList, parent: child); + } + } + } + + /// clean up everything including closing subscriptions + Future dispose() async { + final List futures = []; + futures.add(_noteRepository.closeSubscription(repliesFetchId)); + futures.add(_rootNoteController.close()); + futures.add(_replyNotesController.close()); + + await Future.wait(futures); + } +} diff --git a/test/unit_test/event_feed/tree_test.dart b/test/unit_test/event_feed/tree_test.dart new file mode 100644 index 00000000..e8e0f6b0 --- /dev/null +++ b/test/unit_test/event_feed/tree_test.dart @@ -0,0 +1,149 @@ +import 'package:camelus/domain_layer/entities/nostr_note.dart'; +import 'package:camelus/domain_layer/entities/nostr_tag.dart'; +import 'package:camelus/domain_layer/usecases/event_feed.dart'; +import 'package:test/test.dart'; + +void main() { + group('Tree', () { + // Create a root note + final rootNote = NostrNote( + id: 'root123', + pubkey: 'pubkey1', + created_at: 1635000000, + kind: 1, + content: 'This is the root note.', + sig: 'sig1', + tags: [], + ); + + // Create replies to the root note + final reply1 = NostrNote( + id: 'reply1', + pubkey: 'pubkey2', + created_at: 1635000100, + kind: 1, + content: 'This is a reply to the root note.', + sig: 'sig2', + tags: [NostrTag(type: 'e', value: 'root123', marker: 'root')], + ); + + final reply2 = NostrNote( + id: 'reply2', + pubkey: 'pubkey3', + created_at: 1635000200, + kind: 1, + content: 'Another reply to the root note.', + sig: 'sig3', + tags: [NostrTag(type: 'e', value: 'root123', marker: 'root')], + ); + + // Create replies to the replies + final nestedReply1 = NostrNote( + id: 'nestedReply1', + pubkey: 'pubkey4', + created_at: 1635000300, + kind: 1, + content: 'A reply to the first reply.', + sig: 'sig4', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'reply1', marker: 'reply'), + ], + ); + + final nestedReply2 = NostrNote( + id: 'nestedReply2', + pubkey: 'pubkey5', + created_at: 1635000400, + kind: 1, + content: 'A reply to the second reply.', + sig: 'sig5', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'reply2', marker: 'reply'), + ], + ); + + // Create replies to the replies to the replies + final nestedNestedReply1 = NostrNote( + id: 'nestedNestedReply1', + pubkey: 'pubkey6', + created_at: 1635000500, + kind: 1, + content: 'A reply to the first nested reply.', + sig: 'sig6', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'nestedReply1', marker: 'reply'), + ], + ); + + final nestedNestedReply2 = NostrNote( + id: 'nestedNestedReply2', + pubkey: 'pubkey7', + created_at: 1635000600, + kind: 1, + content: 'A reply to the second nested reply.', + sig: 'sig7', + tags: [ + NostrTag(type: 'e', value: 'root123', marker: 'root'), + NostrTag(type: 'e', value: 'nestedReply1', marker: 'reply'), + ], + ); + + final notFoundReply = NostrNote( + id: 'notFoundReply', + pubkey: 'pubkey8', + created_at: 1635000700, + kind: 1, + content: 'A reply to a note that does not exist.', + sig: 'sig8', + tags: [ + NostrTag(type: 'e', value: 'notFound', marker: 'root'), + ], + ); + + // Create a list of all notes + final List allValidReplies = [ + reply1, + reply2, + nestedReply1, + nestedReply2, + nestedNestedReply1, + nestedNestedReply2, + notFoundReply, + ]; + + test('test building tree', () { + final tree = EventFeed.buildRepliesTree( + rootNoteId: rootNote.id, + replies: allValidReplies, + ); + for (final node in tree) { + node.printTree(); + } + + // first level replies + expect(tree.length, 2); + expect(tree[0].value.id, equals(reply1.id)); + expect(tree[1].value.id, equals(reply2.id)); + + // second level replies + expect(tree[0].children.length, 1); + expect(tree[0].children[0].value.id, equals(nestedReply1.id)); + + expect(tree[1].children.length, 1); + expect(tree[1].children[0].value.id, equals(nestedReply2.id)); + + // third level replies + + expect(tree[0].children[0].children.length, 2); + expect(tree[0].children[0].children[0].value.id, + equals(nestedNestedReply1.id)); + expect(tree[0].children[0].children[1].value.id, + equals(nestedNestedReply2.id)); + + expect(tree[1].children[0].children.length, 0); + }); + }); +}