Skip to content

Commit

Permalink
wip: build note tree
Browse files Browse the repository at this point in the history
  • Loading branch information
leo-lox committed Nov 2, 2024
1 parent ff67ae8 commit 8016145
Showing 3 changed files with 293 additions and 0 deletions.
24 changes: 24 additions & 0 deletions lib/domain_layer/entities/tree_node.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class TreeNode<T> {
T value;
List<TreeNode<T>> children;

TreeNode(this.value) : children = <TreeNode<T>>[];

void addChild(TreeNode<T> 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();
}
}
120 changes: 120 additions & 0 deletions lib/domain_layer/usecases/event_feed.dart
Original file line number Diff line number Diff line change
@@ -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<NostrNote> _rootNoteController =
StreamController<NostrNote>();
Stream<NostrNote> get rootNoteStream => _rootNoteController.stream;

final StreamController<NostrNote> _replyNotesController =
StreamController<NostrNote>();
Stream<NostrNote> get replyNotesStream => _replyNotesController.stream;

EventFeed(this._noteRepository, this._follow);

Future<void> subscribeToReplyNotes({
required String rootNoteId,
required int since,
}) async {
final replyNotes = _noteRepository.subscribeReplyNotes(
requestId: repliesFetchId,
rootNoteId: rootNoteId,
);

replyNotes.listen((event) {
_replyNotesController.add(event);
});
}

Future<void> 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<TreeNode<NostrNote>> buildRepliesTree({
required String rootNoteId,
required List<NostrNote> replies,
}) {
final List<NostrNote> workingList = List.from(replies, growable: true);
workingList.sort((a, b) => a.created_at.compareTo(b.created_at));
final List<TreeNode<NostrNote>> 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<NostrNote>(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<NostrNote> workingList,
required TreeNode<NostrNote> parent,
}) {
for (var i = 0; i < workingList.length; i++) {
final reply = workingList[i];

if (reply.getDirectReply?.value == parent.value.id) {
final child = TreeNode<NostrNote>(reply);
parent.addChild(child);
workingList.remove(reply);
i--; // Adjust index after removal
_buildSubtree(workingList: workingList, parent: child);
}
}
}

/// clean up everything including closing subscriptions
Future<void> dispose() async {
final List<Future> futures = [];
futures.add(_noteRepository.closeSubscription(repliesFetchId));
futures.add(_rootNoteController.close());
futures.add(_replyNotesController.close());

await Future.wait(futures);
}
}
149 changes: 149 additions & 0 deletions test/unit_test/event_feed/tree_test.dart
Original file line number Diff line number Diff line change
@@ -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<NostrNote> 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);
});
});
}

0 comments on commit 8016145

Please sign in to comment.