diff --git a/lib/domain_layer/usecases/profile_feed.dart b/lib/domain_layer/usecases/profile_feed.dart new file mode 100644 index 00000000..2585e50e --- /dev/null +++ b/lib/domain_layer/usecases/profile_feed.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import '../entities/nostr_note.dart'; +import '../repositories/note_repository.dart'; + +/// used to get the feeds for a user profile +class ProfileFeed { + final NoteRepository _noteRepository; + + final String userFeedFreshId = "profile-fresh"; + final String userFeedTimelineFetchId = "profile-timeline"; + + // root streams + final StreamController _rootNotesController = + StreamController(); + Stream get rootNotesStream => _rootNotesController.stream; + + final StreamController _newRootNotesController = + StreamController(); + Stream get newRootNotesStream => _newRootNotesController.stream; + + // root and reply streams + final StreamController _rootAndReplyNotesController = + StreamController(); + Stream get rootAndReplyNotesStream => + _rootAndReplyNotesController.stream; + + final StreamController _newRootAndReplyNotesController = + StreamController(); + Stream get newRootAndReplyNotesStream => + _newRootAndReplyNotesController.stream; + + ProfileFeed( + this._noteRepository, + ); + + Future subscribeToFreshNotes({ + required String npub, + required int since, + }) async { + final newNotesStream = _noteRepository.subscribeTextNotesByAuthors( + authors: [npub], + requestId: userFeedFreshId, + since: since, + ); + + newNotesStream.listen((event) { + _newRootAndReplyNotesController.add(event); + if (event.isRoot) { + _newRootNotesController.add(event); + } + }); + } + + /// load later timelineevents then + void loadMore({ + required int oltherThen, + required String pubkey, + }) { + fetchFeedEvents( + npub: pubkey, + requestId: "loadMore-profile-", + limit: 20, + until: oltherThen - 1, // -1 to not get dublicates + ); + } + + void fetchFeedEvents({ + required String npub, + required String requestId, + int? since, + int? until, + int? limit, + List? eTags, + }) async { + // get contacts of user + final mynotesStream = _noteRepository.getTextNotesByAuthors( + authors: [npub], + requestId: requestId, + since: since, + until: until, + limit: limit, + eTags: eTags, + ); + + mynotesStream.listen((event) { + _rootAndReplyNotesController.add(event); + if (event.isRoot) { + _rootNotesController.add(event); + } + }); + } + + /// integrate new root notes into main feed + void integrateRootNotes(List events) { + for (final event in events) { + _rootNotesController.add(event); + } + } + + void integrateRootAndReplyNotes(List events) { + for (final event in events) { + _rootAndReplyNotesController.add(event); + } + } + + /// clean up everything including closing subscriptions + Future dispose() async { + final List futures = []; + futures.add(_noteRepository.closeSubscription(userFeedTimelineFetchId)); + futures.add(_rootNotesController.close()); + futures.add(_newRootNotesController.close()); + futures.add(_rootAndReplyNotesController.close()); + futures.add(_newRootAndReplyNotesController.close()); + + await Future.wait(futures); + } +} diff --git a/lib/presentation_layer/providers/main_feed_provider.dart b/lib/presentation_layer/providers/main_feed_provider.dart index 6f80697f..e4f25a01 100644 --- a/lib/presentation_layer/providers/main_feed_provider.dart +++ b/lib/presentation_layer/providers/main_feed_provider.dart @@ -40,8 +40,8 @@ final mainFeedStateProvider = ); class MainFeedState extends FamilyNotifier { - StreamSubscription? _mainFeedSub; - StreamSubscription? _newNotesSub; + StreamSubscription? _rootNotesSub; + StreamSubscription? _newRootNotesSub; StreamSubscription? _rootAndReplySub; StreamSubscription? _newRootAndReplySub; @@ -55,8 +55,8 @@ class MainFeedState extends FamilyNotifier { newRootAndReplyNotes: [], ); - _mainFeedSub?.cancel(); - _newNotesSub?.cancel(); + _rootNotesSub?.cancel(); + _newRootNotesSub?.cancel(); _rootAndReplySub?.cancel(); _newRootAndReplySub?.cancel(); await mainFeed.dispose(); @@ -91,13 +91,13 @@ class MainFeedState extends FamilyNotifier { appDbP.save(key: 'main_feed_cache_cutoff', value: now.toString()); // Timeline subscription - _mainFeedSub = mainFeed.rootNotesStream + _rootNotesSub = mainFeed.rootNotesStream .bufferTime(const Duration(milliseconds: 500)) .where((events) => events.isNotEmpty) .listen(_addRootTimelineEvents); // New notes subscription - _newNotesSub = mainFeed.newRootNotesStream + _newRootNotesSub = mainFeed.newRootNotesStream .bufferTime(const Duration(seconds: 1)) .where((events) => events.isNotEmpty) .listen(_addNewRootEvents); diff --git a/lib/presentation_layer/providers/profile_feed_provider.dart b/lib/presentation_layer/providers/profile_feed_provider.dart new file mode 100644 index 00000000..cbd09015 --- /dev/null +++ b/lib/presentation_layer/providers/profile_feed_provider.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:riverpod/riverpod.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../data_layer/repositories/note_repository_impl.dart'; +import '../../domain_layer/entities/feed_view_model.dart'; +import '../../domain_layer/entities/nostr_note.dart'; +import '../../domain_layer/repositories/note_repository.dart'; +import '../../domain_layer/usecases/profile_feed.dart'; +import 'db_app_provider.dart'; +import 'event_verifier.dart'; +import 'ndk_provider.dart'; + +final profileFeedProvider = Provider((ref) { + final ndk = ref.watch(ndkProvider); + + final eventVerifier = ref.watch(eventVerifierProvider); + + final DartNdkSource dartNdkSource = DartNdkSource(ndk); + + final NoteRepository noteRepository = NoteRepositoryImpl( + dartNdkSource: dartNdkSource, + eventVerifier: eventVerifier, + ); + + final ProfileFeed profileFeed = ProfileFeed(noteRepository); + + return profileFeed; +}); + +final profileFeedStateProvider = + NotifierProvider.family( + ProfileFeedState.new, +); + +class ProfileFeedState extends FamilyNotifier { + StreamSubscription? _rootNotesSub; + StreamSubscription? _newRootNotesSub; + StreamSubscription? _rootAndReplySub; + StreamSubscription? _newRootAndReplySub; + + /// closes everthing and resets the state + Future resetStateDispose() async { + final profileFeed = ref.read(profileFeedProvider); + state = FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + + _rootNotesSub?.cancel(); + _newRootNotesSub?.cancel(); + _rootAndReplySub?.cancel(); + _newRootAndReplySub?.cancel(); + await profileFeed.dispose(); + } + + @override + FeedViewModel build(String arg) { + _initSubscriptions(arg); + return FeedViewModel( + timelineRootNotes: [], + newRootNotes: [], + timelineRootAndReplyNotes: [], + newRootAndReplyNotes: [], + ); + } + + void _initSubscriptions(String pubkey) async { + final profileFeed = ref.read(profileFeedProvider); + final appDbP = ref.read(dbAppProvider); + + final dbCutOffKey = 'profile_feed_cache_cutoff_$pubkey'; + + // [cutoff] is seperates the feed into old and new notes + // basically marking the cache point + final lastFetch = await appDbP.read(dbCutOffKey); + int cutoff = 0; + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (lastFetch != null) { + cutoff = int.parse(lastFetch); + } else { + cutoff = now; + } + // Save the current time as the new cutoff + appDbP.save(key: dbCutOffKey, value: now.toString()); + + // Timeline subscription + _rootNotesSub = profileFeed.rootNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootTimelineEvents); + + // New notes subscription + _newRootNotesSub = profileFeed.newRootNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootEvents); + + _rootAndReplySub = profileFeed.rootAndReplyNotesStream + .bufferTime(const Duration(milliseconds: 500)) + .where((events) => events.isNotEmpty) + .listen(_addRootAndReplyTimelineEvents); + + _newRootAndReplySub = profileFeed.newRootAndReplyNotesStream + .bufferTime(const Duration(seconds: 1)) + .where((events) => events.isNotEmpty) + .listen(_addNewRootAndReplyEvents); + + // Initial fetch + profileFeed.fetchFeedEvents( + npub: pubkey, + requestId: "startup-profile", + limit: 20, + until: cutoff, + ); + profileFeed.subscribeToFreshNotes(npub: pubkey, since: cutoff); + } + + void _addRootTimelineEvents(List events) { + state = state.copyWith( + timelineRootNotes: [...state.timelineRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootEvents(List events) { + state = state.copyWith( + newRootNotes: [...state.newRootNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addRootAndReplyTimelineEvents(List events) { + state = state.copyWith( + timelineRootAndReplyNotes: [ + ...state.timelineRootAndReplyNotes, + ...events + ]..sort((a, b) => b.created_at.compareTo(a.created_at))); + } + + void _addNewRootAndReplyEvents(List events) { + state = state.copyWith( + newRootAndReplyNotes: [...state.newRootAndReplyNotes, ...events] + ..sort((a, b) => b.created_at.compareTo(a.created_at))); + } +} diff --git a/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart new file mode 100644 index 00000000..851f1bdf --- /dev/null +++ b/lib/presentation_layer/routes/nostr/nostr_page/profile_feed_root_view.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../../domain_layer/entities/nostr_note.dart'; +import '../../../atoms/new_posts_available.dart'; +import '../../../atoms/refresh_indicator_no_need.dart'; +import '../../../components/note_card/note_card_container.dart'; +import '../../../components/note_card/sceleton_note.dart'; +import '../../../providers/navigation_bar_provider.dart'; +import '../../../providers/profile_feed_provider.dart'; + +class ProfileFeedRootView extends ConsumerStatefulWidget { + final String pubkey; + + final ScrollController scrollControllerFeed; + + // attaches from outside, used for scroll animation + const ProfileFeedRootView({ + super.key, + required this.pubkey, + required this.scrollControllerFeed, + }); + + @override + ConsumerState createState() => + _ProfileFeedRootViewState(); +} + +class _ProfileFeedRootViewState extends ConsumerState { + final List _subscriptions = []; + + NostrNote get latestNote => + ref.watch(profileFeedStateProvider(widget.pubkey)).timelineRootNotes.last; + + _loadMore() { + if (ref + .watch(profileFeedStateProvider(widget.pubkey)) + .timelineRootNotes + .length < + 2) return; + log("_loadMore()"); + final mainFeedProvider = ref.read(profileFeedProvider); + mainFeedProvider.loadMore( + oltherThen: latestNote.created_at, + pubkey: widget.pubkey, + ); + } + + void _setupNavBarHomeListener() { + var provider = ref.read(navigationBarProvider); + _subscriptions.add(provider.onTabHome.listen((event) { + _handleHomeBarTab(); + })); + } + + void _handleHomeBarTab() { + final newNotesLenth = + ref.watch(profileFeedStateProvider(widget.pubkey)).newRootNotes.length; + if (newNotesLenth > 0) { + _integrateNewNotes(); + return; + } + ref.watch(navigationBarProvider).resetNewNotesCount(); + // scroll to top + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + ); + } + + void _integrateNewNotes() { + final newNotesP = ref.watch(profileFeedStateProvider(widget.pubkey)); + + final notesToIntegrate = newNotesP; + ref + .watch(profileFeedProvider) + .integrateRootNotes(notesToIntegrate.newRootNotes); + + // delte new notes in FeedNew + newNotesP.newRootNotes.clear(); + + ref.watch(navigationBarProvider).resetNewNotesCount(); + + widget.scrollControllerFeed.animateTo( + widget.scrollControllerFeed.position.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + @override + void initState() { + super.initState(); + _setupNavBarHomeListener(); + } + + @override + void dispose() { + _disposeSubscriptions(); + + super.dispose(); + } + + void _disposeSubscriptions() { + for (var s in _subscriptions) { + s.cancel(); + } + } + + @override + Widget build(BuildContext context) { + final mainFeedStateP = ref.watch(profileFeedStateProvider(widget.pubkey)); + + ref.watch(navigationBarProvider).newNotesCount = + mainFeedStateP.newRootNotes.length; + + return Stack( + children: [ + refreshIndicatorNoNeed( + onRefresh: () { + return Future.delayed(const Duration(milliseconds: 0)); + }, + child: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == mainFeedStateP.timelineRootNotes.length) { + return SkeletonNote(renderCallback: _loadMore()); + } + + final event = mainFeedStateP.timelineRootNotes[index]; + + return NoteCardContainer( + key: PageStorageKey(event.id), + note: event, + ); + }, + childCount: mainFeedStateP.timelineRootNotes.length + 1, + ), + )), + if (mainFeedStateP.newRootNotes.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 20), + child: newPostsAvailable( + name: "${mainFeedStateP.newRootNotes.length} new posts", + onPressed: () { + _integrateNewNotes(); + }), + ), + ], + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart index 658e35a6..85485e55 100644 --- a/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart +++ b/lib/presentation_layer/routes/nostr/nostr_page/user_feed_original_view.dart @@ -32,9 +32,6 @@ class UserFeedOriginalView extends ConsumerStatefulWidget { class _UserFeedOriginalViewState extends ConsumerState { final List _subscriptions = []; - // new ######### - // final List timelineEvents = []; // Removed this line - NostrNote get latestNote => ref.watch(mainFeedStateProvider(widget.pubkey)).timelineRootNotes.last;