From a84371a0b382a25233d179934a2b6bf31209c2be Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 23 Apr 2025 16:24:41 -0400 Subject: [PATCH] - Restructures the old static datasource model into dynamic filter assemblers. - Moves filter assemblers, viewModels and DAL classes to their own packages. - Creates Composable observers for Users and Notes - Deletes most of the secondary LiveData objects in the move to Flow - Manipulates nostr filters depending on the account of the current screen, not a global account. - Unifies all FilterAssembly lifecycle watchers to a few classes. - Prepares to separate The Nostr Client as an Engine of Amethyst. - Moves the pre-caching processor of new events from the datasource to the accountViewModel - Reorganizes search to be per-screen basis - Moves authentication to a fixed Coordinator class for all accounts in all relays. - Moves NOTIFY command to its own coordinator class for all accounts - Moves the connection between filters and cache to its own class. - Significantly reduces the dependency on a single ServiceManager class. --- .../ThreadDualAxisChartAssemblerTest.kt | 2 +- .../com/vitorpamplona/amethyst/Amethyst.kt | 51 +- .../com/vitorpamplona/amethyst/DebugUtils.kt | 60 +- .../vitorpamplona/amethyst/ServiceManager.kt | 80 --- .../vitorpamplona/amethyst/model/Account.kt | 44 +- .../vitorpamplona/amethyst/model/Channel.kt | 30 +- .../amethyst/model/LocalCache.kt | 108 +++- .../com/vitorpamplona/amethyst/model/Note.kt | 139 ++--- .../com/vitorpamplona/amethyst/model/User.kt | 122 ++--- .../service/NostrAccountDataSource.kt | 514 ------------------ .../service/NostrChannelDataSource.kt | 118 ---- .../service/NostrChatroomDataSource.kt | 108 ---- .../service/NostrCommunityDataSource.kt | 71 --- .../service/NostrDiscoveryDataSource.kt | 455 ---------------- .../NostrSearchEventOrUserDataSource.kt | 232 -------- .../amethyst/service/NostrThreadDataSource.kt | 102 ---- .../amethyst/service/ZapPaymentHandler.kt | 3 +- .../service/connectivity/ConnectivityFlow.kt | 4 - .../eventCache/MemoryTrimmingService.kt | 40 +- .../service/images/ImageLoaderSetup.kt | 2 +- .../service/playback/pip/BackgroundMedia.kt | 20 - .../SimultaneousPlaybackCalculator.kt | 20 - .../relayClient/CacheClientConnector.kt | 73 +++ .../relayClient/KeyDataSourceSubscription.kt | 53 ++ .../compose/AccountDataSourceSubscription.kt} | 40 +- .../authCommand/model/AuthCoordinator.kt | 71 +++ .../model/ListWithUniqueSetCache.kt | 64 +++ .../compose}/DisplayNotifyMessages.kt | 24 +- .../compose}/NotifyRequestDialog.kt | 2 +- .../notifyCommand/model/NotifyCoordinator.kt | 39 ++ .../notifyCommand/model/NotifyRequest.kt | 26 + .../model/NotifyRequestsCache.kt | 52 ++ .../QueryBasedSubscriptionOrchestrator.kt | 83 +++ .../RelaySubscriptionsCoordinator.kt | 107 ++++ ...laySubscriptionsCoordinatorSubscription.kt | 38 ++ .../account/AccountFilterAssembler.kt | 299 ++++++++++ .../AccountFilterAssemblerSubscription.kt | 45 ++ .../channel/ChannelFinderFilterAssembler.kt} | 64 +-- ...hannelFinderFilterAssemblerSubscription.kt | 45 ++ .../reqCommand/channel/ChannelObservers.kt | 34 ++ .../event/EventFinderFilterAssembler.kt} | 147 ++--- .../EventFinderFilterAssemblerSubscription.kt | 45 ++ .../reqCommand/event/EventObservers.kt | 239 ++++++++ .../nwc/NWCPaymentFilterAssembler.kt} | 43 +- .../user/UserFinderFilterAssembler.kt} | 68 ++- .../UserFinderFilterAssemblerSubscription.kt | 45 ++ .../reqCommand/user/UserObservers.kt | 137 +++++ ...tableQueryBasedSubscriptionOrchestrator.kt | 100 ++++ .../searchCommand/SearchFilterAssembler.kt | 250 +++++++++ .../TextSearchDataSourceSubscription.kt | 37 ++ .../UserSearchDataSourceSubscription.kt | 37 ++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 5 +- .../amethyst/ui/actions/EditPostView.kt | 11 - .../amethyst/ui/actions/EditPostViewModel.kt | 3 - .../ui/actions/JoinUserOrChannelView.kt | 455 ---------------- .../amethyst/ui/actions/NewPostViewModel.kt | 4 - .../amethyst/ui/components/ClickableRoute.kt | 13 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../markdown/MarkdownMediaRenderer.kt | 4 +- .../amethyst/ui/dal/AdditiveFeedFilter.kt | 49 ++ .../amethyst/ui/dal/FeedFilter.kt | 31 -- .../amethyst/ui/feeds/InvalidatableContent.kt | 4 +- .../ui/feeds/WatchLifecycleAndUpdateModel.kt | 37 ++ .../DisappearingScaffold.kt | 3 +- .../ui/navigation/AccountSwitchBottomSheet.kt | 7 +- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/navigation/AppTopBar.kt | 9 +- .../amethyst/ui/navigation/DrawerContent.kt | 12 +- .../ui/navigation/FeedFilterSpinner.kt | 18 +- .../amethyst/ui/navigation/RouteMaker.kt | 2 - .../amethyst/ui/note/BadgeCompose.kt | 7 +- .../amethyst/ui/note/ChannelCardCompose.kt | 149 +++-- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 3 +- .../amethyst/ui/note/MultiSetCompose.kt | 4 +- .../ui/note/NIP05VerificationDisplay.kt | 15 +- .../amethyst/ui/note/NoteCompose.kt | 20 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 3 +- .../amethyst/ui/note/PollNote.kt | 4 +- .../amethyst/ui/note/ReactionsRow.kt | 67 +-- .../amethyst/ui/note/ReplyInformation.kt | 4 +- .../ui/note/UpdateReactionTypeDialog.kt | 18 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 2 +- .../amethyst/ui/note/UserProfilePicture.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 11 +- .../amethyst/ui/note/WatchNoteEvent.kt | 5 +- .../amethyst/ui/note/ZapNoteCompose.kt | 16 +- .../WatchAndLoadMyEmojiList.kt | 29 +- .../userSuggestions/ShowUserSuggestionList.kt | 47 +- .../userSuggestions/UserSuggestionState.kt | 64 ++- .../note/elements/AddInboxRelayForDMCard.kt | 5 +- .../ui/note/elements/DefaultImageHeader.kt | 13 +- .../ui/note/elements/DisplayReward.kt | 4 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 2 +- .../amethyst/ui/note/elements/ForkInfo.kt | 24 +- .../ui/note/elements/ZapTheDevsCard.kt | 5 +- .../amethyst/ui/note/types/Badge.kt | 21 +- .../amethyst/ui/note/types/CommunityHeader.kt | 40 +- .../amethyst/ui/note/types/Emoji.kt | 26 +- .../amethyst/ui/note/types/FileStorage.kt | 7 +- .../amethyst/ui/note/types/Git.kt | 9 +- .../amethyst/ui/note/types/Highlight.kt | 7 +- .../ui/note/types/InteractiveStory.kt | 51 +- .../amethyst/ui/note/types/LiveActivity.kt | 4 +- .../ui/note/types/TextModification.kt | 4 +- .../amethyst/ui/note/types/TorrentComment.kt | 39 +- .../amethyst/ui/screen/FeedViewModel.kt | 201 ------- .../amethyst/ui/screen/UserFeedViewModel.kt | 24 - .../loggedIn/AccountFeedContentStates.kt | 22 +- .../ui/screen/loggedIn/AccountViewModel.kt | 31 +- .../ui/screen/loggedIn/LoggedInPage.kt | 25 +- .../ui/screen/loggedIn/NewPostScreen.kt | 53 +- .../loggedIn/PrecacheNewNotesProcessor.kt | 117 ++++ .../loggedIn/bookmarks/BookmarkListScreen.kt | 20 +- .../dal/BookmarkPrivateFeedFilter.kt | 3 +- .../dal/BookmarkPrivateFeedViewModel.kt | 38 ++ .../dal/BookmarkPublicFeedFilter.kt | 3 +- .../dal/BookmarkPublicFeedViewModel.kt | 38 ++ .../loggedIn/chats/feed/DrawAuthorInfo.kt | 4 +- .../chats/privateDM/ChatroomByAuthorScreen.kt | 2 +- .../chats/privateDM/ChatroomScreen.kt | 2 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 46 +- .../privateDM}/dal/ChatroomFeedFilter.kt | 4 +- .../privateDM/dal/ChatroomFeedViewModel.kt | 39 ++ .../datasource/ChatroomFilterAssembler.kt | 128 +++++ .../ChatroomFilterAssemblerSubscription.kt | 43 ++ .../privateDM/header/RoomNameOnlyDisplay.kt | 9 +- .../privateDM/send/ChatNewMessageViewModel.kt | 3 - .../chats/privateDM/send/NewGroupDMScreen.kt | 11 - .../chats/publicChannels/ChannelScreen.kt | 2 +- .../chats/publicChannels/ChannelView.kt | 52 +- .../publicChannels}/dal/ChannelFeedFilter.kt | 4 +- .../dal/ChannelFeedViewModel.kt | 39 ++ .../datasource/ChannelFilterAssembler.kt | 160 ++++++ .../ChannelFilterAssemblerSubscription.kt | 43 ++ .../header/LongPublicChatChannelHeader.kt | 3 +- .../header/ShortPublicChatChannelHeader.kt | 4 +- .../LongLiveActivityChannelHeader.kt | 4 +- .../ShortLiveActivityChannelHeader.kt | 4 +- .../nip53LiveActivities/ShowVideoStreaming.kt | 2 + .../send/ChannelNewMessageViewModel.kt | 3 - .../chats/rooms/ChatroomHeaderCompose.kt | 17 +- .../rooms}/dal/ChatroomListKnownFeedFilter.kt | 5 +- .../rooms}/dal/ChatroomListNewFeedFilter.kt | 5 +- .../ChatroomListFilterAssembler.kt} | 105 ++-- ...hatroomListFilterAssemblerSubscription.kt} | 28 +- .../rooms/singlepane/MessagesSinglePane.kt | 12 +- .../chats/rooms/twopane/ChatroomListPane.kt | 10 +- .../chats/rooms/twopane/MessagesTwoPane.kt | 3 +- .../loggedIn/communities/CommunityScreen.kt | 44 +- .../communities}/dal/CommunityFeedFilter.kt | 4 +- .../communities/dal/CommunityFeedViewModel.kt | 39 ++ .../datasource/CommunityFilterAssembler.kt | 73 +++ .../CommunityFilterAssemblerSubscription.kt | 41 ++ .../loggedIn/discover/DiscoverScreen.kt | 30 +- .../discover}/dal/DiscoverChatFeedFilter.kt | 6 +- .../dal/DiscoverCommunityFeedFilter.kt | 8 +- .../discover}/dal/DiscoverLiveFeedFilter.kt | 6 +- .../dal/DiscoverMarketplaceFeedFilter.kt | 6 +- .../discover}/dal/DiscoverNIP89FeedFilter.kt | 6 +- .../datasource/DiscoveryFilterAssembler.kt | 509 +++++++++++++++++ .../DiscoveryFilterAssemblerSubscription.kt | 42 ++ .../screen/loggedIn/drafts/DraftListScreen.kt | 10 +- .../drafts}/dal/DraftEventsFeedFilter.kt | 4 +- .../drafts/dal/DraftEventsFeedViewModel.kt | 38 ++ .../dvms/DvmContentDiscoveryScreen.kt | 46 +- .../dal/NIP90ContentDiscoveryFeedViewModel.kt | 42 ++ .../NIP90ContentDiscoveryResponseFilter.kt | 10 +- .../screen/loggedIn/geohash/GeoHashScreen.kt | 96 +--- .../geohash}/dal/GeoHashFeedFilter.kt | 7 +- .../geohash/dal/GeoHashFeedViewModel.kt | 44 ++ .../datasource/GeoHashFilterAssembler.kt} | 78 +-- .../GeoHashFilterAssemblerSubscription.kt | 40 ++ .../screen/loggedIn/hashtag/HashtagScreen.kt | 54 +- .../hashtag}/dal/HashtagFeedFilter.kt | 7 +- .../hashtag/dal/HashtagFeedViewModel.kt | 44 ++ .../datasource/HashtagFilterAssembler.kt} | 88 +-- .../HashtagFilterAssemblerSubscription.kt | 40 ++ .../ui/screen/loggedIn/home/HomeScreen.kt | 32 +- .../home}/dal/HomeConversationsFeedFilter.kt | 7 +- .../home}/dal/HomeNewThreadFeedFilter.kt | 7 +- .../home/datasource/HomeFilterAssembler.kt} | 201 ++++--- .../HomeFilterAssemblerSubscription.kt | 42 ++ .../{ => keyBackup}/AccountBackupDialog.kt | 4 +- .../notifications/CardFeedContentState.kt | 2 +- .../notifications/NotificationScreen.kt | 23 +- .../dal/NotificationFeedFilter.kt | 11 +- .../screen/loggedIn/profile/ProfileScreen.kt | 171 +++--- .../profile/bookmarks/TabBookmarks.kt | 3 +- .../dal/UserProfileBookmarksFeedFilter.kt | 4 +- .../UserProfileBookmarksFeedViewModel.kt} | 7 +- .../conversations/TabNotesConversations.kt | 3 +- .../dal/UserProfileConversationsFeedFilter.kt | 4 +- .../UserProfileConversationsFeedViewModel.kt} | 7 +- .../datasource/UserProfileFilterAssembler.kt} | 121 +++-- .../UserProfileFilterAssemblerSubscription.kt | 41 ++ .../dal/UserProfileFollowersFeedFilter.kt | 3 +- .../UserProfileFollowersUserFeedViewModel.kt} | 7 +- .../dal/UserProfileFollowsFeedFilter.kt | 3 +- .../UserProfileFollowsUserFeedViewModel.kt} | 7 +- .../profile/gallery/GalleryCardCompose.kt | 5 +- .../loggedIn/profile/gallery/GalleryThumb.kt | 4 +- .../loggedIn/profile/gallery/TabGallery.kt | 3 +- .../dal/UserProfileGalleryFeedFilter.kt | 11 +- .../UserProfileGalleryFeedViewModel.kt} | 7 +- .../profile/header/DrawAdditionalInfo.kt | 14 +- .../loggedIn/profile/header/DrawBanner.kt | 7 +- .../loggedIn/profile/header/ProfileHeader.kt | 4 +- .../header/apps/DisplayAppRecommendations.kt | 2 +- ...=> UserAppRecommendationsFeedViewModel.kt} | 5 +- ...UserProfileAppRecommendationsFeedFilter.kt | 4 +- .../loggedIn/profile/header/apps/WatchApp.kt | 4 +- .../profile/header/badges/DisplayBadges.kt | 23 +- .../profile/mutual/TabMutualConversations.kt | 3 +- .../dal/UserProfileMutualFeedFilter.kt | 4 +- .../UserProfileMutualFeedViewModel.kt} | 7 +- .../profile/newthreads/TabNotesNewThreads.kt | 3 +- .../dal/UserProfileNewThreadFeedFilter.kt | 4 +- .../UserProfileNewThreadsFeedViewModel.kt} | 7 +- .../profile/reports/ReportsTabHeader.kt | 2 +- .../loggedIn/profile/reports/TabReports.kt | 3 +- .../reports/WatchReportsAndUpdateFeed.kt | 3 +- .../UserProfileReportFeedViewModel.kt} | 7 +- .../dal/UserProfileReportsFeedFilter.kt | 4 +- .../loggedIn/profile/zaps/TabReceivedZaps.kt | 3 +- .../profile/zaps/WatchZapsAndUpdateFeed.kt | 3 +- .../zaps}/dal/UserProfileZapsFeedFilter.kt | 3 +- .../UserProfileZapsFeedViewModel.kt} | 8 +- .../{ => redirect}/LoadRedirectScreen.kt | 7 +- .../loggedIn/{ => report}/ReportNoteDialog.kt | 5 +- .../search}/AddInboxRelayForSearchCard.kt | 8 +- .../search/AddSearchRelayListDialog.kt | 4 +- .../loggedIn/search/SearchBarViewModel.kt | 135 +++-- .../ui/screen/loggedIn/search/SearchScreen.kt | 106 +--- .../loggedIn/settings/AppSettingsScreen.kt | 2 +- .../settings/SecurityFiltersScreen.kt | 27 +- .../loggedIn/settings/StringFeedViewModel.kt | 15 - .../settings}/dal/HiddenAccountsFeedFilter.kt | 25 +- .../dal/HiddenAccountsFeedViewModel.kt | 36 ++ .../settings/dal/HiddenWordsFeedFilter.kt | 36 ++ .../settings/dal/HiddenWordsFeedViewModel.kt | 38 ++ .../settings/dal/SpammerAccountsFeedFilter.kt | 36 ++ .../dal/SpammerAccountsFeedViewModel.kt | 36 ++ .../loggedIn/threadview/ThreadFeedView.kt | 2 +- .../loggedIn/threadview/ThreadScreen.kt | 52 +- .../threadview/dal/LevelFeedViewModel.kt | 95 ++++ .../threadview}/dal/ThreadFeedFilter.kt | 3 +- .../threadview/dal/ThreadFeedViewModel.kt | 37 ++ .../datasources/ThreadFilterAssembler.kt | 109 ++++ .../ThreadFilterAssemblerSubscription.kt | 41 ++ .../ui/screen/loggedIn/video/VideoScreen.kt | 27 +- .../loggedIn/video}/dal/VideoFeedFilter.kt | 26 +- .../video/datasource/VideoFilterAssembler.kt} | 146 +++-- .../VideoFilterAssemblerSubscription.kt | 42 ++ .../zaps/UserProfileZapsFeedFilterTest.kt | 2 +- .../ammolite/relays/NostrClient.kt | 84 +-- .../ammolite/relays/NostrDataSource.kt | 314 ----------- .../vitorpamplona/ammolite/relays/Relay.kt | 3 +- .../relays/datasources/EventCollector.kt | 53 +- .../relays/datasources/NostrDataSource.kt | 275 ++++++++++ .../relays/datasources/RelayAuthenticator.kt | 51 ++ .../RelayInsertConfirmationCollector.kt | 59 ++ .../relays/datasources/RelayNotifier.kt | 54 ++ .../relays/{ => datasources}/Subscription.kt | 13 +- .../datasources/SubscriptionOrchestrator.kt | 216 ++++++++ .../relays/datasources/SubscriptionSet.kt | 45 ++ .../relays/datasources/SubscriptionStats.kt | 60 ++ .../amethyst/commons/data/LargeCache.kt | 2 + .../nip01Core/relay/SimpleClientRelay.kt | 30 + .../nip03Timestamp/VerificationState.kt | 3 + .../nip03Timestamp/VerificationStateCache.kt | 15 +- .../nip10Notes/content/ContentHashTags.kt | 2 + .../quartz/nip37Drafts/DraftBuilder.kt | 6 +- 272 files changed, 7055 insertions(+), 5068 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt => service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt} (57%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/components => service/relayClient/notifyCommand/compose}/DisplayNotifyMessages.kt (74%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/actions => service/relayClient/notifyCommand/compose}/NotifyRequestDialog.kt (98%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/QueryBasedSubscriptionOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{NostrSingleChannelDataSource.kt => relayClient/reqCommand/channel/ChannelFinderFilterAssembler.kt} (63%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{NostrSingleEventDataSource.kt => relayClient/reqCommand/event/EventFinderFilterAssembler.kt} (75%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{NostrLnZapPaymentResponseDataSource.kt => relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt} (60%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{NostrSingleUserDataSource.kt => relayClient/reqCommand/user/UserFinderFilterAssembler.kt} (64%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/MutableQueryBasedSubscriptionOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{screen/loggedIn => layouts}/DisappearingScaffold.kt (98%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/bookmarks}/dal/BookmarkPrivateFeedFilter.kt (94%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/bookmarks}/dal/BookmarkPublicFeedFilter.kt (94%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/chats/privateDM}/dal/ChatroomFeedFilter.kt (91%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/chats/publicChannels}/dal/ChannelFeedFilter.kt (90%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/chats/rooms}/dal/ChatroomListKnownFeedFilter.kt (97%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/chats/rooms}/dal/ChatroomListNewFeedFilter.kt (96%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrChatroomListDataSource.kt => ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt} (61%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/{WatchAccountForListScreen.kt => datasource/ChatroomListFilterAssemblerSubscription.kt} (66%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/communities}/dal/CommunityFeedFilter.kt (94%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/discover}/dal/DiscoverChatFeedFilter.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/discover}/dal/DiscoverCommunityFeedFilter.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/discover}/dal/DiscoverLiveFeedFilter.kt (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/discover}/dal/DiscoverMarketplaceFeedFilter.kt (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/discover}/dal/DiscoverNIP89FeedFilter.kt (94%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/drafts}/dal/DraftEventsFeedFilter.kt (92%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/dvms}/dal/NIP90ContentDiscoveryResponseFilter.kt (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/geohash}/dal/GeoHashFeedFilter.kt (92%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrGeohashDataSource.kt => ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt} (57%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/hashtag}/dal/HashtagFeedFilter.kt (92%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrHashtagDataSource.kt => ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt} (60%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/home}/dal/HomeConversationsFeedFilter.kt (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/home}/dal/HomeNewThreadFeedFilter.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrHomeDataSource.kt => ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt} (62%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{ => keyBackup}/AccountBackupDialog.kt (99%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/notifications}/dal/NotificationFeedFilter.kt (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/bookmarks}/dal/UserProfileBookmarksFeedFilter.kt (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/{NostrUserProfileBookmarksFeedViewModel.kt => dal/UserProfileBookmarksFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/conversations}/dal/UserProfileConversationsFeedFilter.kt (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/{NostrUserProfileConversationsFeedViewModel.kt => dal/UserProfileConversationsFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrUserProfileDataSource.kt => ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt} (66%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/followers}/dal/UserProfileFollowersFeedFilter.kt (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/{NostrUserProfileFollowersUserFeedViewModel.kt => dal/UserProfileFollowersUserFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/follows}/dal/UserProfileFollowsFeedFilter.kt (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/{NostrUserProfileFollowsUserFeedViewModel.kt => dal/UserProfileFollowsUserFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/gallery}/dal/UserProfileGalleryFeedFilter.kt (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/{NostrUserProfileGalleryFeedViewModel.kt => dal/UserProfileGalleryFeedViewModel.kt} (91%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/{NostrUserAppRecommendationsFeedViewModel.kt => UserAppRecommendationsFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{dal => screen/loggedIn/profile/header/apps}/UserProfileAppRecommendationsFeedFilter.kt (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/mutual}/dal/UserProfileMutualFeedFilter.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/{NostrUserProfileMutualFeedViewModel.kt => dal/UserProfileMutualFeedViewModel.kt} (91%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/newthreads}/dal/UserProfileNewThreadFeedFilter.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/{NostrUserProfileNewThreadsFeedViewModel.kt => dal/UserProfileNewThreadsFeedViewModel.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/{NostrUserProfileReportFeedViewModel.kt => dal/UserProfileReportFeedViewModel.kt} (87%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/reports}/dal/UserProfileReportsFeedFilter.kt (91%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/profile/zaps}/dal/UserProfileZapsFeedFilter.kt (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/{NostrUserProfileZapsFeedViewModel.kt => dal/UserProfileZapsFeedViewModel.kt} (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{ => redirect}/LoadRedirectScreen.kt (96%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{ => report}/ReportNoteDialog.kt (97%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{note/elements => screen/loggedIn/search}/AddInboxRelayForSearchCard.kt (93%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{relays => }/search/AddSearchRelayListDialog.kt (96%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/settings}/dal/HiddenAccountsFeedFilter.kt (74%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/threadview}/dal/ThreadFeedFilter.kt (96%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/{ => screen/loggedIn/video}/dal/VideoFeedFilter.kt (82%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/NostrVideoDataSource.kt => ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt} (61%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt => ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt (57%) create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/{ => datasources}/Subscription.kt (93%) create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionOrchestrator.kt create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 215e089cd..46d529d97 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -25,7 +25,7 @@ import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index e630f1b57..82ef4e015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -26,7 +26,9 @@ import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager +import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService import com.vitorpamplona.amethyst.service.images.ImageCacheFactory import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup import com.vitorpamplona.amethyst.service.location.LocationState @@ -38,6 +40,10 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory +import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.ammolite.relays.NostrClient @@ -53,6 +59,7 @@ import java.io.File class Amethyst : Application() { val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}" + // Exists to avoid exceptions stopping the coroutine val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable) @@ -60,7 +67,7 @@ class Amethyst : Application() { val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) - // Key cache to download and decrypt encrypted files before caching them. + // Key cache service to download and decrypt encrypted files before caching them. val keyCache = EncryptionKeyCache() // App services that should be run as soon as there are subscribers to their flows @@ -68,9 +75,10 @@ class Amethyst : Application() { val torManager = TorManager(this, applicationIOScope) val connManager = ConnectivityManager(this, applicationIOScope) - // Service that will run at all times. + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() + // creates okHttpClients based on the conditions of the connection and tor status val okHttpClients = DualHttpClientManager( userAgent = appAgent, @@ -80,21 +88,52 @@ class Amethyst : Application() { scope = applicationIOScope, ) + // Connects the NostrClient class with okHttp val factory = OkHttpWebSocket.BuilderFactory { _, useProxy -> okHttpClients.getHttpClient(useProxy) } + // Provides a relay pool val client: NostrClient = NostrClient(factory) + // Caches all events in Memory + val cache: LocalCache = LocalCache + + // Verifies and inserts in the cache from all relays, all subscriptions + val cacheClientConnector = CacheClientConnector(client, cache) + + // Show messages from the Relay and controls their dismissal + val notifyCoordinator = NotifyCoordinator(client) + + // Authenticates with relays. + val authCoordinator = AuthCoordinator(client) + + // Organizes cache clearing + val trimmingService = MemoryTrimmingService(cache) + + // Coordinates all subscriptions for the Nostr Client + val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope) + + // Trash. val serviceManager = ServiceManager(client, applicationIOScope) + // saves the .content of NIP-95 blobs in disk to save memory val nip95cache: File by lazy { Nip95CacheFactory.new(this) } + + // local video cache with disk + memory val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) } + + // image cache in disk for coil val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) } + + // image cache in memory for coil val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) } + // Application-wide ots verification cache val otsVerifCache by lazy { VerificationStateCache() } + + // Application-wide block height request cache val otsBlockHeightCache by lazy { OtsBlockHeightCache() } override fun onCreate() { @@ -103,7 +142,7 @@ class Amethyst : Application() { instance = this - if (isDebug()) { + if (isDebug) { Logging.setup() } @@ -124,10 +163,8 @@ class Amethyst : Application() { fun contentResolverFn(): ContentResolver = contentResolver - fun isDebug() = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" - fun setImageLoader(shouldUseTor: Boolean?) = - ImageLoaderSetup.setup(this, diskCache, memoryCache, isDebug()) { + ImageLoaderSetup.setup(this, diskCache, memoryCache) { shouldUseTor?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false) } @@ -142,7 +179,7 @@ class Amethyst : Application() { super.onTrimMemory(level) Log.d("AmethystApp", "onTrimMemory $level") applicationIOScope.launch(Dispatchers.Default) { - serviceManager.trimMemory() + trimmingService.run(null, LocalPreferences.allSavedAccounts()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index f0001431c..9ddebbee7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -27,45 +27,15 @@ import android.os.Debug import android.util.Log import androidx.core.content.getSystemService import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrAccountDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.service.NostrCommunityDataSource -import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource -import com.vitorpamplona.amethyst.service.NostrGeohashDataSource -import com.vitorpamplona.amethyst.service.NostrHashtagDataSource -import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource -import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource -import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource -import com.vitorpamplona.amethyst.service.NostrThreadDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.service.NostrVideoDataSource +import kotlin.time.measureTimedValue + +val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" fun debugState(context: Context) { Amethyst.instance.client .allSubscriptions() .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") } - NostrAccountDataSource.printCounter() - NostrChannelDataSource.printCounter() - NostrChatroomDataSource.printCounter() - NostrChatroomListDataSource.printCounter() - NostrCommunityDataSource.printCounter() - NostrDiscoveryDataSource.printCounter() - NostrHashtagDataSource.printCounter() - NostrGeohashDataSource.printCounter() - NostrHomeDataSource.printCounter() - NostrSearchEventOrUserDataSource.printCounter() - NostrSingleChannelDataSource.printCounter() - NostrSingleEventDataSource.printCounter() - NostrSingleUserDataSource.printCounter() - NostrThreadDataSource.printCounter() - NostrUserProfileDataSource.printCounter() - NostrVideoDataSource.printCounter() - val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024) @@ -174,3 +144,27 @@ fun debugState(context: Context) { Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ") } } + +inline fun logTime( + debugMessage: String, + block: () -> T, +): T = + if (isDebug) { + val (result, elapsed) = measureTimedValue(block) + Log.d("DEBUG-TIME", "$elapsed: $debugMessage") + result + } else { + block() + } + +inline fun logTime( + debugMessage: (T) -> Unit, + block: () -> T, +): T = + if (isDebug) { + val (result, elapsed) = measureTimedValue(block) + Log.d("DEBUG-TIME", "$elapsed: ${debugMessage(result)}") + result + } else { + block() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 4eebd0271..12a591746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -25,35 +25,12 @@ import androidx.compose.runtime.Stable import coil3.annotation.DelicateCoilApi import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrAccountDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.service.NostrCommunityDataSource -import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource -import com.vitorpamplona.amethyst.service.NostrGeohashDataSource -import com.vitorpamplona.amethyst.service.NostrHashtagDataSource -import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource -import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource -import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource -import com.vitorpamplona.amethyst.service.NostrThreadDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.service.NostrVideoDataSource -import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking @Stable class ServiceManager( @@ -67,8 +44,6 @@ class ServiceManager( private var collectorJob: Job? = null - private val trimmingService = MemoryTrimmingService() - private fun start(account: Account) { this.account = account start() @@ -104,39 +79,6 @@ class ServiceManager( } } - // start services - NostrAccountDataSource.account = myAccount - NostrHomeDataSource.account = myAccount - NostrChatroomListDataSource.account = myAccount - NostrVideoDataSource.account = myAccount - NostrDiscoveryDataSource.account = myAccount - - NostrAccountDataSource.otherAccounts = - runBlocking { - LocalPreferences.allSavedAccounts().mapNotNull { - try { - it.npub.bechToBytes().toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - } - } - - // Notification Elements - NostrHomeDataSource.start() - NostrAccountDataSource.start() - GlobalScope.launch(Dispatchers.IO) { - delay(3000) - NostrChatroomListDataSource.start() - NostrDiscoveryDataSource.start() - NostrVideoDataSource.start() - } - - // More Info Data Sources - NostrSingleEventDataSource.start() - NostrSingleChannelDataSource.start() - NostrSingleUserDataSource.start() isStarted = true } } @@ -147,24 +89,6 @@ class ServiceManager( collectorJob?.cancel() collectorJob = null - NostrAccountDataSource.stopSync() - NostrHomeDataSource.stopSync() - NostrChannelDataSource.stopSync() - NostrChatroomDataSource.stopSync() - NostrChatroomListDataSource.stopSync() - NostrDiscoveryDataSource.stopSync() - - NostrCommunityDataSource.stopSync() - NostrHashtagDataSource.stopSync() - NostrGeohashDataSource.stopSync() - NostrSearchEventOrUserDataSource.stopSync() - NostrSingleChannelDataSource.stopSync() - NostrSingleEventDataSource.stopSync() - NostrSingleUserDataSource.stopSync() - NostrThreadDataSource.stopSync() - NostrUserProfileDataSource.stopSync() - NostrVideoDataSource.stopSync() - client.reconnect(null) isStarted = false } @@ -173,10 +97,6 @@ class ServiceManager( LocalCache.cleanObservers() } - suspend fun trimMemory() { - trimmingService.run(account) - } - // This method keeps the pause/start in a Syncronized block to // avoid concurrent pauses and starts. @Synchronized diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f60cf2f5b..b5160f609 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -33,10 +33,10 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.ots.OtsResolverBuilder +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS @@ -220,14 +220,6 @@ class Account( var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) - data class PaymentRequest( - val relayUrl: String, - val description: String, - ) - - var transientPaymentRequestDismissals: Set = emptySet() - val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - @Immutable class LiveFollowList( val authors: Set = emptySet(), @@ -1101,7 +1093,7 @@ class Account( val liveHiddenUsers = flowHiddenUsers.asLiveData() val decryptBookmarks: LiveData by lazy { - userProfile().live().innerBookmarks.switchMap { userState -> + userProfile().live().bookmarks.switchMap { userState -> liveData(Dispatchers.IO) { if (userState.user.latestBookmarkList == null) { emit(null) @@ -1190,22 +1182,6 @@ class Account( ) } - fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { - if ( - !this.transientPaymentRequests.value.contains(paymentRequest) && - !this.transientPaymentRequestDismissals.contains(paymentRequest) - ) { - this.transientPaymentRequests.value += paymentRequest - } - } - - fun dismissPaymentRequest(request: PaymentRequest) { - if (this.transientPaymentRequests.value.contains(request)) { - this.transientPaymentRequests.value -= request - this.transientPaymentRequestDismissals += request - } - } - private var userProfileCache: User? = null fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } @@ -1564,14 +1540,15 @@ class Account( nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, signer) { event -> - val wcListener = - NostrLnZapPaymentResponseDataSource( + val filter = + NWCPaymentQueryState( fromServiceHex = nip47.pubKeyHex, toUserHex = event.pubKey, replyingToHex = event.id, - authSigner = signer, ) - wcListener.startSync() + + Amethyst.instance.sources.nwc + .subscribe(filter) LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } } @@ -1583,9 +1560,12 @@ class Account( forceProxy = shouldUseTorForTrustedRelays(), // this is trusted. read = true, write = true, - feedTypes = wcListener.feedTypes, + feedTypes = setOf(FeedType.WALLET_CONNECT), ), - onDone = { wcListener.destroy() }, + onDone = { + Amethyst.instance.sources.nwc + .unsubscribe(filter) + }, ) onSent() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 442e44fb0..15e60c193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.commons.data.LargeCache -import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -230,12 +229,11 @@ abstract class Channel( // Observers line up here. val live: ChannelLiveData = ChannelLiveData(this) - fun pruneOldAndHiddenMessages(account: Account): Set { + fun pruneOldMessages(): Set { val important = notes - .filter { key, it -> - it.author?.let { author -> account.isHidden(author) } == false - }.sortedWith(DefaultFeedOrder) + .values() + .sortedWith(DefaultFeedOrder) .take(500) .toSet() @@ -245,6 +243,18 @@ abstract class Channel( return toBeRemoved.toSet() } + + fun pruneHiddenMessages(account: Account): Set { + val hidden = + notes + .filter { key, it -> + it.author?.let { author -> account.isHidden(author) } == true + }.toSet() + + hidden.forEach { notes.remove(it.idHex) } + + return hidden.toSet() + } } class ChannelLiveData( @@ -263,16 +273,6 @@ class ChannelLiveData( } } } - - override fun onActive() { - super.onActive() - NostrSingleChannelDataSource.add(channel) - } - - override fun onInactive() { - super.onInactive() - NostrSingleChannelDataSource.remove(channel) - } } class ChannelState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index afc02d8d6..9486ebd0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.data.DeletionIndex import com.vitorpamplona.amethyst.commons.data.LargeCache import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag -import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.Relay @@ -171,7 +170,26 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.ConcurrentHashMap -object LocalCache { +interface ILocalCache { + fun verifyAndConsume( + event: Event, + relay: Relay?, + ) + + fun justVerify(event: Event): Boolean + + fun consume( + event: DraftEvent, + relay: Relay?, + ) + + fun markAsSeen( + string: String, + relay: Relay, + ) {} +} + +object LocalCache : ILocalCache { val antiSpam = AntiSpamFilter() val users = LargeCache() @@ -185,6 +203,8 @@ object LocalCache { val observablesByKindAndETag = ConcurrentHashMap>>(10) val observablesByKindAndAuthor = ConcurrentHashMap>>(10) + val onNewEvents = mutableListOf<(Note) -> Unit>() + fun observeETag( kind: Int, eventId: HexKey, @@ -452,6 +472,7 @@ object LocalCache { } } } + // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}") } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} @@ -917,7 +938,7 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - author.liveSet?.innerStatuses?.invalidateData() + author.liveSet?.statuses?.invalidateData() refreshObservers(note) } @@ -942,7 +963,7 @@ object LocalCache { if (version.event == null) { version.loadEvent(event, author, emptyList()) - version.liveSet?.innerOts?.invalidateData() + version.liveSet?.ots?.invalidateData() } refreshObservers(version) @@ -1358,7 +1379,7 @@ object LocalCache { mentions.forEach { // doesn't add to reports, but triggers recounts - it.liveSet?.innerReports?.invalidateData() + it.liveSet?.reports?.invalidateData() } } @@ -1604,7 +1625,7 @@ object LocalCache { checkGetOrCreateNote(it.eventId)?.let { editedNote -> modificationCache.remove(editedNote.idHex) // must update list of Notes to quickly update the user. - editedNote.liveSet?.innerModifications?.invalidateData() + editedNote.liveSet?.edits?.invalidateData() } } @@ -1832,6 +1853,8 @@ object LocalCache { username: String, forAccount: Account?, ): List { + if (username.isBlank()) return emptyList() + checkNotInMainThread() val key = decodePublicKeyAsHexOrNull(username) @@ -1884,6 +1907,8 @@ object LocalCache { ): List { checkNotInMainThread() + if (text.isBlank()) return emptyList() + val key = decodeEventIdAsHexOrNull(text) if (key != null) { @@ -1948,6 +1973,8 @@ object LocalCache { fun findChannelsStartingWith(text: String): List { checkNotInMainThread() + if (text.isBlank()) return emptyList() + val key = decodeEventIdAsHexOrNull(text) if (key != null && getChannelIfExists(key) != null) { return listOfNotNull(getChannelIfExists(key)) @@ -2054,11 +2081,9 @@ object LocalCache { } } - fun pruneOldAndHiddenMessages(account: Account) { - checkNotInMainThread() - + fun pruneHiddenMessages(account: Account) { channels.forEach { _, channel -> - val toBeRemoved = channel.pruneOldAndHiddenMessages(account) + val toBeRemoved = channel.pruneHiddenMessages(account) val childrenToBeRemoved = mutableListOf() @@ -2072,7 +2097,31 @@ object LocalCache { if (toBeRemoved.size > 100 || channel.notes.size() > 100) { println( - "PRUNE: ${toBeRemoved.size} messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + } + + fun pruneOldMessages() { + checkNotInMainThread() + + channels.forEach { _, channel -> + val toBeRemoved = channel.pruneOldMessages() + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + removeFromCache(it) + + childrenToBeRemoved.addAll(it.removeAllChildNotes()) + } + + removeFromCache(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", ) } } @@ -2258,7 +2307,7 @@ object LocalCache { } } - fun pruneHiddenMessages(account: Account) { + fun pruneHiddenEvents(account: Account) { checkNotInMainThread() val childrenToBeRemoved = mutableListOf() @@ -2299,15 +2348,32 @@ object LocalCache { println("PRUNE: $removingContactList contact lists") } + override fun markAsSeen( + eventId: String, + relay: Relay, + ) { + val note = getNoteIfExists(eventId) + + note?.event?.let { noteEvent -> + if (noteEvent is AddressableEvent) { + getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) + } + } + + note?.addRelay(relay) + } + // Observers line up here. val live: LocalCacheFlow = LocalCacheFlow() private fun refreshObservers(newNote: Note) { - updateObservables(newNote.event as Event) + val event = newNote.event as Event + updateObservables(event) + onNewEvents.forEach { it(newNote) } live.invalidateData(newNote) } - fun verifyAndConsume( + override fun verifyAndConsume( event: Event, relay: Relay?, ) { @@ -2316,7 +2382,7 @@ object LocalCache { } } - fun justVerify(event: Event): Boolean { + override fun justVerify(event: Event): Boolean { checkNotInMainThread() return if (!event.verify()) { @@ -2332,7 +2398,7 @@ object LocalCache { } } - fun consume( + override fun consume( event: DraftEvent, relay: Relay?, ) { @@ -2639,6 +2705,16 @@ object LocalCache { val note = notes.get(notificationEvent.id) note?.event != null } + + fun copyRelaysFromTo( + from: Note, + to: Event, + ) { + val toNote = getOrCreateNote(to) + from.relays.forEach { + toNote.addRelayBrief(it) + } + } } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 56da5a61c..7fb9825e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -23,15 +23,11 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.distinctUntilChanged import com.vitorpamplona.amethyst.launchAndWaitAll -import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.tryAndWait -import com.vitorpamplona.amethyst.ui.note.combineWith import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.ammolite.relays.Relay @@ -232,7 +228,7 @@ open class Note( this.author = author this.replyTo = replyTo - liveSet?.innerMetadata?.invalidateData() + liveSet?.metadata?.invalidateData() flowSet?.metadata?.invalidateData() } } @@ -240,21 +236,21 @@ open class Note( fun addReply(note: Note) { if (note !in replies) { replies = replies + note - liveSet?.innerReplies?.invalidateData() + liveSet?.replies?.invalidateData() } } fun removeReply(note: Note) { if (note in replies) { replies = replies - note - liveSet?.innerReplies?.invalidateData() + liveSet?.replies?.invalidateData() } } fun removeBoost(note: Note) { if (note in boosts) { boosts = boosts - note - liveSet?.innerBoosts?.invalidateData() + liveSet?.boosts?.invalidateData() } } @@ -285,13 +281,13 @@ open class Note( relays = listOf() lastReactionsDownloadTime = emptyMap() - if (repliesChanged) liveSet?.innerReplies?.invalidateData() - if (reactionsChanged) liveSet?.innerReactions?.invalidateData() - if (boostsChanged) liveSet?.innerBoosts?.invalidateData() + if (repliesChanged) liveSet?.replies?.invalidateData() + if (reactionsChanged) liveSet?.reactions?.invalidateData() + if (boostsChanged) liveSet?.boosts?.invalidateData() if (reportsChanged) { flowSet?.reports?.invalidateData() } - if (zapsChanged) liveSet?.innerZaps?.invalidateData() + if (zapsChanged) liveSet?.zaps?.invalidateData() return toBeRemoved } @@ -310,7 +306,7 @@ open class Note( reactions = reactions + Pair(reaction, newList) } - liveSet?.innerReactions?.invalidateData() + liveSet?.reactions?.invalidateData() } } } @@ -331,28 +327,28 @@ open class Note( if (zaps[note] != null) { zaps = zaps.minus(note) updateZapTotal() - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } else if (zaps.containsValue(note)) { zaps = zaps.filterValues { it != note } updateZapTotal() - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } fun removeZapPayment(note: Note) { if (zapPayments.containsKey(note)) { zapPayments = zapPayments.minus(note) - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } else if (zapPayments.containsValue(note)) { zapPayments = zapPayments.filterValues { it != note } - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note - liveSet?.innerBoosts?.invalidateData() + liveSet?.boosts?.invalidateData() } } @@ -379,7 +375,7 @@ open class Note( val inserted = innerAddZap(zapRequest, zap) if (inserted) { updateZapTotal() - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } } @@ -405,7 +401,7 @@ open class Note( if (zapPayments[zapPaymentRequest] == null) { val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) if (inserted) { - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } } @@ -417,10 +413,10 @@ open class Note( val listOfAuthors = reactions[reaction] if (listOfAuthors == null) { reactions = reactions + Pair(reaction, listOf(note)) - liveSet?.innerReactions?.invalidateData() + liveSet?.reactions?.invalidateData() } else if (!listOfAuthors.contains(note)) { reactions = reactions + Pair(reaction, listOfAuthors + note) - liveSet?.innerReactions?.invalidateData() + liveSet?.reactions?.invalidateData() } } @@ -1001,44 +997,13 @@ class NoteLiveSet( u: Note, ) { // Observers line up here. - val innerMetadata = NoteBundledRefresherLiveData(u) - val innerReactions = NoteBundledRefresherLiveData(u) - val innerBoosts = NoteBundledRefresherLiveData(u) - val innerReplies = NoteBundledRefresherLiveData(u) - val innerZaps = NoteBundledRefresherLiveData(u) - val innerOts = NoteBundledRefresherLiveData(u) - val innerModifications = NoteBundledRefresherLiveData(u) - - val metadata = innerMetadata.map { it } - val reactions = innerReactions.map { it } - val boosts = innerBoosts.map { it } - val replies = innerReplies.map { it } - val zaps = innerZaps.map { it } - - val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged() - - val hasReactions = - innerZaps - .combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> - zapState?.note?.zaps?.isNotEmpty() - ?: false || - boostState?.note?.boosts?.isNotEmpty() ?: false || - reactionState?.note?.reactions?.isNotEmpty() ?: false - }.distinctUntilChanged() - - val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged() - - val reactionCount = - innerReactions - .map { - var total = 0 - it.note.reactions.forEach { total += it.value.size } - total - }.distinctUntilChanged() - - val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged() - - val content = innerMetadata.map { it.note.event?.content ?: "" } + val metadata = NoteBundledRefresherLiveData(u) + val reactions = NoteBundledRefresherLiveData(u) + val boosts = NoteBundledRefresherLiveData(u) + val replies = NoteBundledRefresherLiveData(u) + val zaps = NoteBundledRefresherLiveData(u) + val ots = NoteBundledRefresherLiveData(u) + val edits = NoteBundledRefresherLiveData(u) fun isInUse(): Boolean = metadata.hasObservers() || @@ -1046,22 +1011,17 @@ class NoteLiveSet( boosts.hasObservers() || replies.hasObservers() || zaps.hasObservers() || - hasEvent.hasObservers() || - hasReactions.hasObservers() || - replyCount.hasObservers() || - reactionCount.hasObservers() || - boostCount.hasObservers() || - innerOts.hasObservers() || - innerModifications.hasObservers() + ots.hasObservers() || + edits.hasObservers() fun destroy() { - innerMetadata.destroy() - innerReactions.destroy() - innerBoosts.destroy() - innerReplies.destroy() - innerZaps.destroy() - innerOts.destroy() - innerModifications.destroy() + metadata.destroy() + reactions.destroy() + boosts.destroy() + replies.destroy() + zaps.destroy() + ots.destroy() + edits.destroy() } } @@ -1109,37 +1069,6 @@ class NoteBundledRefresherLiveData( postValue(NoteState(note)) } } - - fun map(transform: (NoteState) -> Y): NoteLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = NoteLoadingLiveData(note, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } -} - -@Stable -class NoteLoadingLiveData( - val note: Note, - initialValue: Y?, -) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - if (note is AddressableNote) { - NostrSingleEventDataSource.addAddress(note) - } else { - NostrSingleEventDataSource.add(note) - } - } - - override fun onInactive() { - super.onInactive() - if (note is AddressableNote) { - NostrSingleEventDataSource.removeAddress(note) - } else { - NostrSingleEventDataSource.remove(note) - } - } } @Immutable class NoteState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 08c8a7c41..d79f81f3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.distinctUntilChanged -import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.ammolite.relays.BundledUpdate @@ -128,7 +125,7 @@ class User( if (event.id == latestBookmarkList?.id) return latestBookmarkList = event - liveSet?.innerBookmarks?.invalidateData() + liveSet?.bookmarks?.invalidateData() } fun clearEOSE() { @@ -142,7 +139,7 @@ class User( latestContactList = event // Update following of the current user - liveSet?.innerFollows?.invalidateData() + liveSet?.follows?.invalidateData() flowSet?.follows?.invalidateData() // Update Followers of the past user list @@ -151,18 +148,18 @@ class User( LocalCache .getUserIfExists(it) ?.liveSet - ?.innerFollowers + ?.followers ?.invalidateData() } (latestContactList)?.unverifiedFollowKeySet()?.forEach { LocalCache .getUserIfExists(it) ?.liveSet - ?.innerFollowers + ?.followers ?.invalidateData() } - liveSet?.innerRelays?.invalidateData() + liveSet?.relays?.invalidateData() flowSet?.relays?.invalidateData() } @@ -172,10 +169,10 @@ class User( val reportsBy = reports[author] if (reportsBy == null) { reports = reports + Pair(author, setOf(note)) - liveSet?.innerReports?.invalidateData() + liveSet?.reports?.invalidateData() } else if (!reportsBy.contains(note)) { reports = reports + Pair(author, reportsBy + note) - liveSet?.innerReports?.invalidateData() + liveSet?.reports?.invalidateData() } } @@ -185,7 +182,7 @@ class User( if (reports[author]?.contains(deleteNote) == true) { reports[author]?.let { reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.innerReports?.invalidateData() + liveSet?.reports?.invalidateData() } } } @@ -196,17 +193,17 @@ class User( ) { if (zaps[zapRequest] == null) { zaps = zaps + Pair(zapRequest, zap) - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } fun removeZap(zapRequestOrZapEvent: Note) { if (zaps.containsKey(zapRequestOrZapEvent)) { zaps = zaps.minus(zapRequestOrZapEvent) - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } else if (zaps.containsValue(zapRequestOrZapEvent)) { zaps = zaps.filter { it.value != zapRequestOrZapEvent } - liveSet?.innerZaps?.invalidateData() + liveSet?.zaps?.invalidateData() } } @@ -259,7 +256,7 @@ class User( val privateChatroom = getOrCreatePrivateChatroom(room) if (msg !in privateChatroom.roomMessages) { privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() + liveSet?.messages?.invalidateData() } } @@ -270,7 +267,7 @@ class User( val privateChatroom = getOrCreatePrivateChatroom(user) if (msg !in privateChatroom.roomMessages) { privateChatroom.addMessageSync(msg) - liveSet?.innerMessages?.invalidateData() + liveSet?.messages?.invalidateData() } } @@ -287,7 +284,7 @@ class User( val privateChatroom = getOrCreatePrivateChatroom(user) if (msg in privateChatroom.roomMessages) { privateChatroom.removeMessageSync(msg) - liveSet?.innerMessages?.invalidateData() + liveSet?.messages?.invalidateData() } } @@ -299,7 +296,7 @@ class User( val privateChatroom = getOrCreatePrivateChatroom(room) if (msg in privateChatroom.roomMessages) { privateChatroom.removeMessageSync(msg) - liveSet?.innerMessages?.invalidateData() + liveSet?.messages?.invalidateData() } } @@ -317,7 +314,7 @@ class User( here.counter++ } - liveSet?.innerRelayInfo?.invalidateData() + liveSet?.relayInfo?.invalidateData() } fun updateUserInfo( @@ -337,7 +334,7 @@ class User( } flowSet?.metadata?.invalidateData() - liveSet?.innerMetadata?.invalidateData() + liveSet?.metadata?.invalidateData() } fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false @@ -484,36 +481,18 @@ class UserFlowSet( class UserLiveSet( u: User, ) { - val innerMetadata = UserBundledRefresherLiveData(u) + val metadata = UserBundledRefresherLiveData(u) // UI Observers line up here. - val innerFollows = UserBundledRefresherLiveData(u) - val innerFollowers = UserBundledRefresherLiveData(u) - val innerReports = UserBundledRefresherLiveData(u) - val innerMessages = UserBundledRefresherLiveData(u) - val innerRelays = UserBundledRefresherLiveData(u) - val innerRelayInfo = UserBundledRefresherLiveData(u) - val innerZaps = UserBundledRefresherLiveData(u) - val innerBookmarks = UserBundledRefresherLiveData(u) - val innerStatuses = UserBundledRefresherLiveData(u) - - // UI Observers line up here. - val metadata = innerMetadata.map { it } - val follows = innerFollows.map { it } - val followers = innerFollowers.map { it } - val reports = innerReports.map { it } - val messages = innerMessages.map { it } - val relays = innerRelays.map { it } - val relayInfo = innerRelayInfo.map { it } - val zaps = innerZaps.map { it } - val bookmarks = innerBookmarks.map { it } - val statuses = innerStatuses.map { it } - - val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() - - val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged() - - val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() + val follows = UserBundledRefresherLiveData(u) + val followers = UserBundledRefresherLiveData(u) + val reports = UserBundledRefresherLiveData(u) + val messages = UserBundledRefresherLiveData(u) + val relays = UserBundledRefresherLiveData(u) + val relayInfo = UserBundledRefresherLiveData(u) + val zaps = UserBundledRefresherLiveData(u) + val bookmarks = UserBundledRefresherLiveData(u) + val statuses = UserBundledRefresherLiveData(u) fun isInUse(): Boolean = metadata.hasObservers() || @@ -525,22 +504,19 @@ class UserLiveSet( relayInfo.hasObservers() || zaps.hasObservers() || bookmarks.hasObservers() || - statuses.hasObservers() || - profilePictureChanges.hasObservers() || - nip05Changes.hasObservers() || - userMetadataInfo.hasObservers() + statuses.hasObservers() fun destroy() { - innerMetadata.destroy() - innerFollows.destroy() - innerFollowers.destroy() - innerReports.destroy() - innerMessages.destroy() - innerRelays.destroy() - innerRelayInfo.destroy() - innerZaps.destroy() - innerBookmarks.destroy() - innerStatuses.destroy() + metadata.destroy() + follows.destroy() + followers.destroy() + reports.destroy() + messages.destroy() + relays.destroy() + relayInfo.destroy() + zaps.destroy() + bookmarks.destroy() + statuses.destroy() } } @@ -570,13 +546,6 @@ class UserBundledRefresherLiveData( postValue(UserState(user)) } } - - fun map(transform: (UserState) -> Y): UserLoadingLiveData { - val initialValue = this.value?.let { transform(it) } - val result = UserLoadingLiveData(user, initialValue) - result.addSource(this) { x -> result.value = transform(x) } - return result - } } @Stable @@ -602,21 +571,6 @@ class UserBundledRefresherFlow( } } -class UserLoadingLiveData( - val user: User, - initialValue: Y?, -) : MediatorLiveData(initialValue) { - override fun onActive() { - super.onActive() - NostrSingleUserDataSource.add(user) - } - - override fun onInactive() { - super.onInactive() - NostrSingleUserDataSource.remove(user) - } -} - @Immutable class UserState( val user: User, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt deleted file mode 100644 index 46179fa34..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ /dev/null @@ -1,514 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.blossom.BlossomServersEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent -import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent -import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -// TODO: Migrate this to a property of AccountVi -object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { - lateinit var account: Account - var otherAccounts = listOf() - - val latestEOSEs = EOSEAccount() - val hasLoadedTheBasics = mutableMapOf() - - fun createAccountMetadataFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - StatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - PrivateOutboxRelayListEvent.KIND, - ), - authors = listOf(account.userProfile().pubkeyHex), - limit = 20, - ), - ) - - fun createOtherAccountsBaseFilter(): TypedFilter? { - val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex } - if (otherAuthors.isEmpty()) return null - return TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - MuteListEvent.KIND, - PeopleListEvent.KIND, - ), - authors = otherAuthors, - limit = otherAuthors.size * 20, - ), - ) - } - - fun createAccountSettingsFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 100, - ), - ) - - fun createAccountSettings2Filter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(AppSpecificDataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), - limit = 1, - ), - ) - - fun createAccountReportsFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(DraftEvent.KIND, ReportEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList, - ), - ) - - fun createAccountLastPostsListFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - authors = listOf(account.userProfile().pubkeyHex), - limit = 400, - ), - ) - - fun createNotificationFilter(): TypedFilter { - var since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList - ?.toMutableMap() - - if (since == null) { - since = - account.connectToRelays.value - .associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - .toMutableMap() - } else { - account.connectToRelays.value.forEach { - val eose = since.get(it.url) - if (eose == null) { - since.put(it.url, EOSETime(TimeUtils.oneWeekAgo())) - } - } - } - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - PollNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - LnZapPaymentResponseEvent.KIND, - ChannelMessageEvent.KIND, - BadgeAwardEvent.KIND, - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 4000, - since = since, - ), - ) - } - - fun createNotificationFilter2(): TypedFilter { - val since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultNotificationFollowList.value) - ?.relayList - ?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - GitReplyEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - HighlightEvent.KIND, - CommentEvent.KIND, - CalendarDateSlotEvent.KIND, - CalendarTimeSlotEvent.KIND, - CalendarRSVPEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 400, - since = since, - ), - ) - } - - fun createGiftWrapsToMeFilter() = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get("&&((GIFTWRAPS_EOSE))&&") - ?.relayList - ?.mapValues { - EOSETime(it.value.time - TimeUtils.twoDays()) - }, - ), - ) - - val accountChannel = - requestNewChannel { time, relayUrl -> - if (hasLoadedTheBasics[account.userProfile()] != null) { - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultNotificationFollowList.value, - relayUrl, - time, - ) - - latestEOSEs.addOrUpdate( - account.userProfile(), - "&&((GIFTWRAPS_EOSE))&&", - relayUrl, - time, - ) - } else { - hasLoadedTheBasics[account.userProfile()] = true - - invalidateFilters() - } - } - - override fun consume( - event: Event, - relay: Relay, - ) { - if (LocalCache.justVerify(event)) { - consumeAlreadyVerified(event, relay) - } - } - - fun consumeAlreadyVerified( - event: Event, - relay: Relay, - ) { - checkNotInMainThread() - - when (event) { - is OtsEvent -> { - // verifies new OTS upon arrival - Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver) - } - - is PrivateOutboxRelayListEvent -> { - val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) - val noteEvent = note?.event - if (noteEvent == null || event.createdAt > noteEvent.createdAt) { - event.privateTags(account.signer) { - LocalCache.justConsume(event, relay) - } - } - } - - is DraftEvent -> { - // Avoid decrypting over and over again if the event already exist. - - if (!event.isDeleted()) { - val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) - val noteEvent = note?.event - if (noteEvent != null) { - if (event.createdAt > noteEvent.createdAt || relay.brief !in note.relays) { - LocalCache.consume(event, relay) - } - } else { - // decrypts - event.cachedDraft(account.signer) {} - - LocalCache.justConsume(event, relay) - } - } - } - - is GiftWrapEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - val noteEvent = note?.event as? GiftWrapEvent - if (noteEvent != null) { - if (relay.brief !in note.relays) { - LocalCache.justConsume(noteEvent, relay) - - noteEvent.innerEventId?.let { - (LocalCache.getNoteIfExists(it)?.event as? Event)?.let { - this.consumeAlreadyVerified(it, relay) - } - } ?: run { - event.unwrap(account.signer) { - this.consume(it, relay) - noteEvent.innerEventId = it.id - } - } - } - } else { - // new event - event.unwrap(account.signer) { - LocalCache.justConsume(event, relay) - this.consume(it, relay) - } - } - } - - is SealedRumorEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - val noteEvent = note?.event as? SealedRumorEvent - if (noteEvent != null) { - if (relay.brief !in note.relays) { - LocalCache.justConsume(noteEvent, relay) - - noteEvent.innerEventId?.let { - (LocalCache.getNoteIfExists(it)?.event as? Event)?.let { - LocalCache.justConsume(it, relay) - } - } ?: run { - event.unseal(account.signer) { - LocalCache.justConsume(it, relay) - noteEvent.innerEventId = it.id - } - } - } - } else { - // new event - event.unseal(account.signer) { - LocalCache.justConsume(event, relay) - LocalCache.justConsume(it, relay) - } - } - } - - is LnZapEvent -> { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note?.event == null) { - event.zapRequest?.let { - if (it.isPrivateZap()) { - it.decryptPrivateZap(account.signer) {} - } - } - - LocalCache.justConsume(event, relay) - } - } - - else -> { - LocalCache.justConsume(event, relay) - } - } - } - - override fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - checkNotInMainThread() - - super.markAsSeenOnRelay(eventId, relay) - - val note = LocalCache.getNoteIfExists(eventId) ?: return - val noteEvent = note.event ?: return - markInnerAsSeenOnRelay(noteEvent, relay) - } - - private fun markInnerAsSeenOnRelay( - newNoteEvent: Event, - relay: Relay, - ) { - markInnerAsSeenOnRelay(newNoteEvent.id, relay) - } - - private fun markInnerAsSeenOnRelay( - eventId: HexKey, - relay: Relay, - ) { - val note = LocalCache.getNoteIfExists(eventId) - - if (note != null) { - note.addRelay(relay) - - val noteEvent = note.event - if (noteEvent is GiftWrapEvent) { - noteEvent.innerEventId?.let { - markInnerAsSeenOnRelay(it, relay) - } - } else if (noteEvent is SealedRumorEvent) { - noteEvent.innerEventId?.let { - markInnerAsSeenOnRelay(it, relay) - } - } - } - } - - override fun updateChannelFilters() = - if (hasLoadedTheBasics[account.userProfile()] != null) { - // gets everything about the user logged in - accountChannel.typedFilters = - listOfNotNull( - createAccountMetadataFilter(), - createAccountSettings2Filter(), - createNotificationFilter(), - createNotificationFilter2(), - createGiftWrapsToMeFilter(), - createAccountReportsFilter(), - createAccountSettingsFilter(), - createAccountLastPostsListFilter(), - createOtherAccountsBaseFilter(), - ).ifEmpty { null } - } else { - // just the basics. - accountChannel.typedFilters = - listOf( - createAccountMetadataFilter(), - createAccountSettingsFilter(), - createAccountSettings2Filter(), - ).ifEmpty { null } - } - - override fun auth( - relay: Relay, - challenge: String, - ) { - super.auth(relay, challenge) - - if (this::account.isInitialized) { - account.sendAuthEvent(relay, challenge) - } - } - - override fun notify( - relay: Relay, - description: String, - ) { - super.notify(relay, description) - - if (this::account.isInitialized) { - account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt deleted file mode 100644 index 310b9d14f..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent - -object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") { - var account: Account? = null - var channel: Channel? = null - - fun loadMessagesBetween( - account: Account, - channel: Channel, - ) { - this.account = account - this.channel = channel - resetFilters() - } - - fun clear() { - account = null - channel = null - } - - fun createMessagesByMeToChannelFilter(): TypedFilter? { - val myAccount = account ?: return null - - if (channel is PublicChatChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) - } else if (channel is LiveActivitiesChannel) { - // Brings on messages by the user from all other relays. - // Since we ship with write to public, read from private only - // this guarantees that messages from the author do not disappear. - return TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - authors = listOf(myAccount.userProfile().pubkeyHex), - limit = 50, - ), - ) - } - return null - } - - fun createMessagesToChannelFilter(): TypedFilter? { - if (channel is PublicChatChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) - } else if (channel is LiveActivitiesChannel) { - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), - tags = mapOf("a" to listOfNotNull(channel?.idHex)), - limit = 200, - ), - ) - } - return null - } - - val messagesChannel = requestNewChannel() - - override fun updateChannelFilters() { - messagesChannel.typedFilters = - listOfNotNull( - createMessagesToChannelFilter(), - createMessagesByMeToChannelFilter(), - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt deleted file mode 100644 index 130c17a37..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey - -object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") { - lateinit var account: Account - private var withRoom: ChatroomKey? = null - - private val latestEOSEs = EOSEAccount() - - fun loadMessagesBetween( - accountIn: Account, - withRoom: ChatroomKey, - ) { - this.account = accountIn - this.withRoom = withRoom - resetFilters() - } - - fun createMessagesToMeFilter(): TypedFilter? { - val myPeer = withRoom - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = myPeer.users.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null - } - } - - fun createMessagesFromMeFilter(): TypedFilter? { - val myPeer = withRoom - - return if (myPeer != null) { - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to myPeer.users.map { it }), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(withRoom.hashCode().toString()) - ?.relayList, - ), - ) - } else { - null - } - } - - fun clearEOSEs(account: Account) { - latestEOSEs.removeDataFor(account.userProfile()) - } - - val inandoutChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time) - } - - override fun updateChannelFilters() { - inandoutChannel.typedFilters = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - ).ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt deleted file mode 100644 index 70e74ee5b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrCommunityDataSource.kt +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent - -object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") { - private var communityToWatch: AddressableNote? = null - - private fun createLoadCommunityFilter(): TypedFilter? { - val myCommunityToWatch = communityToWatch ?: return null - - val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null - - val authors = - community - .moderators() - .map { it.pubKey } - .plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)) - - if (authors.isEmpty()) return null - - return TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - authors = authors, - tags = - mapOf( - "a" to listOf(myCommunityToWatch.address.toValue()), - ), - kinds = listOf(CommunityPostApprovalEvent.KIND), - limit = 500, - ), - ) - } - - val loadCommunityChannel = requestNewChannel() - - override fun updateChannelFilters() { - loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null } - } - - fun loadCommunity(note: AddressableNote?) { - communityToWatch = note - invalidateFilters() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt deleted file mode 100644 index 6fbad76ec..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { - lateinit var account: Account - - val scope = Amethyst.instance.applicationIOScope - val latestEOSEs = EOSEAccount() - - var job: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveDiscoveryFollowLists.collect { - if (this@NostrDiscoveryDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - } - - fun createMarketplaceFilter(): List { - val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null } - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - ?.ifEmpty { null } - val geohashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - ?.ifEmpty { null } - - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - hashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "t" to - it - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - geohashToLoad?.let { - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(ClassifiedsEvent.KIND), - tags = - mapOf( - "g" to it, - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createNIP89Filter(kTags: List): List = - listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(AppDefinitionEvent.KIND), - limit = 300, - tags = mapOf("k" to kTags), - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - ) - - fun createLiveStreamFilter(): List { - val follows = - account.liveDiscoveryFollowLists.value - ?.authors - ?.toList() - ?.ifEmpty { null } - - val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value - - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = followsRelays, - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - follows?.let { - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - tags = mapOf("p" to it), - kinds = listOf(LiveActivitiesEvent.KIND), - limit = 100, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - }, - ) - } - - fun createPublicChatFilter(): List { - val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null } - val followChats = account.selectedChatsFollowList().toList() - - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ChannelMessageEvent.KIND), - limit = 500, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - if (followChats.isNotEmpty()) { - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - ids = followChats, - kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } else { - null - }, - ) - } - - fun createCommunitiesFilter(): TypedFilter { - val follows = account.liveDiscoveryListAuthorsPerRelay.value - - return TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createLiveStreamGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), - tags = mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createPublicChatsGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND), - tags = - mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesTagsFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.hashtags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = - mapOf( - "t" to - hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), - ), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - fun createCommunitiesGeohashesFilter(): TypedFilter? { - val hashToLoad = - account.liveDiscoveryFollowLists.value - ?.geotags - ?.toList() - - if (hashToLoad.isNullOrEmpty()) return null - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), - tags = mapOf("g" to hashToLoad), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.settings.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ) - } - - val discoveryFeedChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultDiscoveryFollowList.value, - relayUrl, - time, - ) - } - - override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = - createLiveStreamFilter() - .plus(createNIP89Filter(listOf("5300"))) - .plus(createPublicChatFilter()) - .plus(createMarketplaceFilter()) - .plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsTagsFilter(), - createPublicChatsGeohashesFilter(), - ), - ).toList() - .ifEmpty { null } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt deleted file mode 100644 index 0b8a3ee7b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent -import com.vitorpamplona.quartz.experimental.nns.NNSEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress -import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed -import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -import com.vitorpamplona.quartz.utils.Hex -import kotlin.coroutines.cancellation.CancellationException - -object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFeed") { - private var searchString: String? = null - - private fun createAnythingWithIDFilter(): List? { - val mySearchString = searchString - if (mySearchString.isNullOrBlank()) { - return null - } - - val hexToWatch = - try { - val isAStraightHex = - if (Hex.isHex(mySearchString)) { - Hex.decode(mySearchString).toHexKey() - } else { - null - } - - when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) { - is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() - is NPub -> parsed.hex - is NProfile -> parsed.hex - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> parsed.hex - is NEvent -> parsed.hex - is NEmbed -> parsed.event.id - is NRelay -> null - is NAddress -> parsed.aTag() - else -> isAStraightHex - } - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - - val directReferenceFilters = - hexToWatch?.let { - if (it.contains(":")) { - // naddr - listOfNotNull( - ATag.parse(it, null)?.let { aTag -> - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, aTag.kind), - authors = listOfNotNull(aTag.pubKeyHex), - // just to be sure - limit = 5, - ), - ) - }, - ) - } else { - // event ids - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = listOfNotNull(it), - ), - ), - // authors - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(it), - // just to be sure - limit = 5, - ), - ), - ) - } - } ?: emptyList() - - // downloads all the reactions to a given event. - return directReferenceFilters + - listOfNotNull( - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - search = mySearchString, - limit = 1000, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - BadgeDefinitionEvent.KIND, - PeopleListEvent.KIND, - BookmarkListEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - ChannelCreateEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - ChannelMetadataEvent.KIND, - ClassifiedsEvent.KIND, - CommunityDefinitionEvent.KIND, - EmojiPackEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesEvent.KIND, - PollNoteEvent.KIND, - NNSEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), - search = mySearchString, - limit = 100, - ), - ), - ) - } - - val searchChannel = requestNewChannel() - - override fun updateChannelFilters() { - searchChannel.typedFilters = createAnythingWithIDFilter() - } - - fun search(searchString: String) { - if (this.searchString != searchString) { - println("DataSource: ${this.javaClass.simpleName} Search for $searchString") - this.searchString = searchString - invalidateFilters() - } - } - - fun clear() { - if (searchString != null) { - println("DataSource: ${this.javaClass.simpleName} Clear") - searchString = null - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt deleted file mode 100644 index 8a740c138..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service - -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.ThreadAssembler -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter - -object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") { - private var eventToWatch: String? = null - - fun createLoadEventsIfNotLoadedFilter(): List { - val threadToLoad = eventToWatch ?: return emptyList() - - val branch = ThreadAssembler().findThreadFor(threadToLoad) ?: return emptyList() - - val eventsToLoad = - branch.allNotes - .filter { it.event == null } - .map { it.idHex } - .toSet() - .ifEmpty { null } - - val address = if (branch.root is AddressableNote) branch.root.idHex else null - val event = if (branch.root !is AddressableNote) branch.root.idHex else branch.root.event?.id - - return listOfNotNull( - eventsToLoad?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = it.toList(), - ), - ) - }, - event?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "e" to listOf(event), - ), - ), - ) - }, - address?.let { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = - mapOf( - "a" to listOf(address), - ), - ), - ) - }, - ) - } - - val loadEventsChannel = - requestNewChannel { _, _ -> - // Many relays operate with limits in the amount of filters. - // As information comes, the filters will be rotated to get more data. - invalidateFilters() - } - - override fun updateChannelFilters() { - loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter() - } - - fun loadThread(noteId: String?) { - if (eventToWatch != noteId) { - eventToWatch = noteId - - invalidateFilters() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 30530b4a5..aebf9c276 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse @@ -374,7 +373,7 @@ class ZapPaymentHandler( stringRes( context, R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, - user?.toBestDisplayName() ?: splitSetup.mainId(), + toUser?.toBestDisplayName() ?: splitSetup.mainId(), ), null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt index 3b120bced..6a8528604 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt @@ -25,7 +25,6 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.util.Log -import com.vitorpamplona.ammolite.service.checkNotInMainThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose @@ -49,7 +48,6 @@ class ConnectivityFlow( object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) - checkNotInMainThread() Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}") connectivityManager.getNetworkCapabilities(network)?.let { trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData())) @@ -61,7 +59,6 @@ class ConnectivityFlow( networkCapabilities: NetworkCapabilities, ) { super.onCapabilitiesChanged(network, networkCapabilities) - checkNotInMainThread() val isMobile = networkCapabilities.isMeteredOrMobileData() Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile") trySend(ConnectivityStatus.Active(network.networkHandle, isMobile)) @@ -77,7 +74,6 @@ class ConnectivityFlow( } awaitClose { - checkNotInMainThread() Log.d("ConnectivityFlow", "Stopping Connectivity Flow") connectivityManager.unregisterNetworkCallback(networkCallback) trySend(ConnectivityStatus.Off) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index be8d891e8..f3b2e1fd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -21,38 +21,44 @@ package com.vitorpamplona.amethyst.service.eventCache import android.util.Log -import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import java.util.concurrent.atomic.AtomicBoolean -class MemoryTrimmingService { +class MemoryTrimmingService( + val cache: LocalCache, +) { var isTrimmingMemoryMutex = AtomicBoolean(false) - private suspend fun doTrim(account: Account?) { - LocalCache.cleanObservers() - - val accounts = LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + private suspend fun doTrim( + account: Account? = null, + otherAccounts: List, + ) { + cache.cleanObservers() account?.let { - LocalCache.pruneHiddenMessages(it) - LocalCache.pruneOldAndHiddenMessages(it) - NostrChatroomDataSource.clearEOSEs(it) - - LocalCache.pruneContactLists(accounts) - LocalCache.pruneRepliesAndReactions(accounts) - LocalCache.prunePastVersionsOfReplaceables() - LocalCache.pruneExpiredEvents() + cache.pruneHiddenEvents(it) + cache.pruneHiddenMessages(it) } + + val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() + cache.pruneOldMessages() + cache.pruneContactLists(accounts) + cache.pruneRepliesAndReactions(accounts) + cache.prunePastVersionsOfReplaceables() + cache.pruneExpiredEvents() } - suspend fun run(account: Account?) { + suspend fun run( + account: Account?, + otherAccounts: List, + ) { if (isTrimmingMemoryMutex.compareAndSet(false, true)) { Log.d("ServiceManager", "Trimming Memory") try { - doTrim(account) + doTrim(account, otherAccounts) } finally { isTrimmingMemoryMutex.getAndSet(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 6abadd5c7..31097217c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -32,6 +32,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.size.Precision import coil3.svg.SvgDecoder import coil3.util.DebugLogger +import com.vitorpamplona.amethyst.isDebug import okhttp3.Call class ImageLoaderSetup { @@ -40,7 +41,6 @@ class ImageLoaderSetup { app: Application, diskCache: DiskCache, memoryCache: MemoryCache, - isDebug: Boolean, callFactory: () -> Call.Factory, ) { SingletonImageLoader.setUnsafe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index a2aaabdd8..65c05ecc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -24,26 +24,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import kotlinx.coroutines.flow.MutableStateFlow -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ object BackgroundMedia { // background playing mutex. val bgInstance = MutableStateFlow(null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt index cdd74c5a3..216df6619 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt @@ -29,26 +29,6 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.mediacodec.MediaCodecUtil import com.vitorpamplona.amethyst.Amethyst -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ class SimultaneousPlaybackCalculator { companion object { fun isLowMemory(context: Context): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt new file mode 100644 index 000000000..6e8eb394f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.datasources.EventCollector +import com.vitorpamplona.ammolite.relays.datasources.RelayInsertConfirmationCollector +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class CacheClientConnector( + val client: NostrClient, + val cache: LocalCache, +) { + val receiver = + EventCollector(client) { event, relay -> + cache.verifyAndConsume(event, relay) + } + + val confirmationWatcher = + RelayInsertConfirmationCollector(client) { eventId, relay -> + cache.markAsSeen(eventId, relay) + unwrapAndMarkAsSeen(eventId, relay) + } + + fun destroy() { + receiver.destroy() + confirmationWatcher.destroy() + } + + private fun unwrapAndMarkAsSeen( + eventId: HexKey, + relay: Relay, + ) { + val note = LocalCache.getNoteIfExists(eventId) + + if (note != null) { + note.addRelay(relay) + + val noteEvent = note.event + if (noteEvent is GiftWrapEvent) { + noteEvent.innerEventId?.let { + unwrapAndMarkAsSeen(it, relay) + } + } else if (noteEvent is SealedRumorEvent) { + noteEvent.innerEventId?.let { + unwrapAndMarkAsSeen(it, relay) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt new file mode 100644 index 000000000..5d62b1744 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQueryState + +@Composable +fun KeyDataSourceSubscription( + state: T, + dataSource: QueryBasedSubscriptionOrchestrator, +) { + DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } + } +} + +@Composable +fun KeyDataSourceSubscription( + state: T, + dataSource: MutableQueryBasedSubscriptionOrchestrator, +) { + DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 79c751e1d..1aa91b5a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchLifecycleAndRefreshDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -18,29 +18,35 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +package com.vitorpamplona.amethyst.service.relayClient.authCommand.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun WatchLifecycleAndRefreshDataSource(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - } - } +fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } +@Composable +fun RelayAuthSubscription( + accountViewModel: AccountViewModel, + dataSource: AuthCoordinator, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel) { + ScreenAuthAccount(accountViewModel.account) + } + + DisposableEffect(state) { + dataSource.subscribe(state) + onDispose { + dataSource.unsubscribe(state) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt new file mode 100644 index 000000000..92784e27a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import android.util.Log +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.datasources.RelayAuthenticator +import kotlin.collections.forEach + +class ScreenAuthAccount( + val account: Account, +) + +class AuthCoordinator( + client: NostrClient, +) { + private val authWithAccounts = ListWithUniqueSetCache { it.account } + + val receiver = + RelayAuthenticator(client) { challenge, relay -> + authWithAccounts.distinct().forEach { + it.sendAuthEvent(relay, challenge) + } + } + + fun destroy() { + receiver.destroy() + } + + // This is called by main. Keep it really fast. + fun subscribe(account: ScreenAuthAccount?) { + if (account == null) return + + if (isDebug) { + Log.d(this::class.simpleName, "Watch $account") + } + + authWithAccounts.add(account) + } + + // This is called by main. Keep it really fast. + fun unsubscribe(account: ScreenAuthAccount?) { + if (account == null) return + + if (isDebug) { + Log.d(this::class.simpleName, "Unwatch $account") + } + + authWithAccounts.remove(account) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt new file mode 100644 index 000000000..2c599e226 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import androidx.lifecycle.AtomicReference + +class ListWithUniqueSetCache( + val key: (T) -> U, +) { + private val list = AtomicReference(listOf()) + private val cacheSet = AtomicReference?>(setOf()) + + fun isEmpty() = list.get().isEmpty() + + fun add(item: T) = set(list.get() + item) + + fun remove(item: T) = set(list.get() - item) + + fun set(newList: List) { + list.set(newList) + // Invalidate the cache - next read will recompute + cacheSet.set(null) + } + + fun distinct(): Set { + var currentSet = cacheSet.get() + + // Check if the cached set is based on the current list + if (currentSet != null) { + return currentSet + } + + // Compute and attempt to atomically update the cache + val newSet = list.get().mapTo(mutableSetOf(), key) + cacheSet.compareAndSet(currentSet, newSet) + return newSet + } + + fun forEachSubscriber(action: (T) -> Unit) { + list.get().forEach(action) + } + + fun forEachUniqueSubscriber(action: (U) -> Unit) { + distinct().forEach(action) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 1e63e6988..d977481d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NotifyRequestDialog +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -33,11 +34,17 @@ import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, -) { - val openDialogMsg = - accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) +) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav) - openDialogMsg.value?.firstOrNull()?.let { request -> +@Composable +fun DisplayNotifyMessages( + requests: NotifyRequestsCache, + accountViewModel: AccountViewModel, + nav: INav, +) { + val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle(emptySet()) + + openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( title = stringRes( @@ -47,8 +54,7 @@ fun DisplayNotifyMessages( textContent = request.description, accountViewModel = accountViewModel, nav = nav, - ) { - accountViewModel.dismissPaymentRequest(request) - } + onDismiss = { requests.dismissPaymentRequest(request) }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt index 540473a61..a37351b71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NotifyRequestDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/NotifyRequestDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt new file mode 100644 index 000000000..cb189f7ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model + +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.datasources.RelayNotifier + +class NotifyCoordinator( + client: NostrClient, +) { + val requests = NotifyRequestsCache() + + val receiver = + RelayNotifier(client) { message, relay -> + requests.addPaymentRequestIfNew(message, relay.url) + } + + fun destroy() { + receiver.destroy() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt new file mode 100644 index 000000000..39ef56244 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequest.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model + +data class NotifyRequest( + val relayUrl: String, + val description: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt new file mode 100644 index 000000000..339539f02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class NotifyRequestsCache { + val transientPaymentRequestDismissals: MutableStateFlow> = MutableStateFlow(emptySet()) + val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) + + fun addPaymentRequestIfNew( + description: String, + relayUrl: String, + ) { + addPaymentRequestIfNew(NotifyRequest(description, relayUrl)) + } + + fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) { + if ( + !this.transientPaymentRequests.value.contains(paymentRequest) && + !this.transientPaymentRequestDismissals.value.contains(paymentRequest) + ) { + this.transientPaymentRequests.value += paymentRequest + } + } + + fun dismissPaymentRequest(request: NotifyRequest) { + if (this.transientPaymentRequests.value.contains(request)) { + this.transientPaymentRequests.update { it - request } + this.transientPaymentRequestDismissals.update { it + request } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/QueryBasedSubscriptionOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/QueryBasedSubscriptionOrchestrator.kt new file mode 100644 index 000000000..e7d90743a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/QueryBasedSubscriptionOrchestrator.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand + +import android.util.Log +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator +import java.util.concurrent.ConcurrentHashMap + +/** + * Creates a data source where the filters are derived from + * data (keys) that is being watched in the UI. + */ +abstract class QueryBasedSubscriptionOrchestrator( + client: NostrClient, +) : SubscriptionOrchestrator(client) { + private var queries: ConcurrentHashMap = ConcurrentHashMap() + + // This is called by main. Keep it really fast. + fun subscribe(query: T?) { + if (query == null) return + + val wasEmpty = queries.isEmpty() + + queries.put(query, query) + + if (wasEmpty) { + start() + } + + invalidateFilters() + + if (isDebug) { + Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)") + } + } + + // This is called by main. Keep it really fast. + fun unsubscribe(query: T?) { + if (query == null) return + + queries.remove(query) + + invalidateFilters() + + if (queries.isEmpty()) { + stop() + } + + if (isDebug) { + Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)") + } + } + + fun forEachSubscriber(action: (T) -> Unit) { + queries.keys.forEach(action) + } + + final override fun updateSubscriptions() { + updateSubscriptions(queries.keys) + } + + abstract fun updateSubscriptions(keys: Set) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt new file mode 100644 index 000000000..720f174ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler +import com.vitorpamplona.ammolite.relays.NostrClient +import kotlinx.coroutines.CoroutineScope + +class RelaySubscriptionsCoordinator( + cache: LocalCache, + client: NostrClient, + scope: CoroutineScope, +) { + // main one: notifications, dms and account settings + val account = AccountFilterAssembler(client) + + // always running, feed assemblers. + val home = HomeFilterAssembler(client) + val chatroomList = ChatroomListFilterAssembler(client) + val video = VideoFilterAssembler(client) + val discovery = DiscoveryFilterAssembler(client) + + // loaders of content that is not yet in the device. + // they are active when looking at events, users, channels. + val channelFinder = ChannelFinderFilterAssembler(client) + val eventFinder = EventFinderFilterAssembler(client) + val userFinder = UserFinderFilterAssembler(client) + + // active when searching or tagging users. + val search = SearchFilterAssembler(cache, client, scope) + + // active depending on the screen. + val channel = ChannelFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) + val community = CommunityFilterAssembler(client) + val thread = ThreadFilterAssembler(client) + val profile = UserProfileFilterAssembler(client) + val hashtags = HashtagFilterAssembler(client) + val geohashes = GeoHashFilterAssembler(client) + + // active when sending zaps via NWC + val nwc = NWCPaymentFilterAssembler(client) + + val all = + listOf( + account, + home, + chatroomList, + video, + discovery, + channelFinder, + eventFinder, + userFinder, + search, + channel, + chatroom, + community, + thread, + profile, + hashtags, + geohashes, + nwc, + ) + + fun start() = all.forEach { it.start() } + + fun stop() = all.forEach { it.stop() } + + fun destroy() = all.forEach { it.destroy() } + + fun printCounters() = all.forEach { it.stats.printCounter() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt new file mode 100644 index 000000000..d95216430 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinatorSubscription.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.vitorpamplona.amethyst.Amethyst + +@Composable +fun RelaySubscriptionsCoordinatorSubscription() = RelaySubscriptionsCoordinatorSubscription(Amethyst.instance.sources) + +@Composable +fun RelaySubscriptionsCoordinatorSubscription(dataSource: RelaySubscriptionsCoordinator) { + DisposableEffect(Unit) { + dataSource.start() + onDispose { + dataSource.stop() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt new file mode 100644 index 000000000..fd13d9e44 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -0,0 +1,299 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relays.EOSERelayList +import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.blossom.BlossomServersEvent +import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +// This allows multiple screen to be listening to tags, even the same tag +class AccountQueryState( + val account: Account, + val otherAccounts: Set, +) + +class AccountFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + val latestEOSE = EOSERelayList() + var hasLoadedTheBasics: Boolean = false + + fun createAccountMetadataFilter(authorsHexes: List): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + StatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + PrivateOutboxRelayListEvent.KIND, + ), + authors = authorsHexes, + limit = 20 * authorsHexes.size, + ), + ) + + fun createOtherAccountsBaseFilter(otherAccounts: List): TypedFilter? { + if (otherAccounts.isEmpty()) return null + return TypedFilter( + types = EVENT_FINDER_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + MuteListEvent.KIND, + PeopleListEvent.KIND, + ), + authors = otherAccounts, + limit = otherAccounts.size * 20, + ), + ) + } + + fun createAccountSettingsFilter(authorsHexes: List): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + PeopleListEvent.KIND, + MuteListEvent.KIND, + BadgeProfilesEvent.KIND, + EmojiPackSelectionEvent.KIND, + ), + authors = authorsHexes, + limit = 100 * authorsHexes.size, + ), + ) + + fun createAccountSettings2Filter(authorsHexes: List): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + AppSpecificDataEvent.KIND, + ), + authors = authorsHexes, + tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), + limit = 2 * authorsHexes.size, + ), + ) + + fun createAccountLastPostsListFilter(authorsHexes: List): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + authors = authorsHexes, + limit = 500, + ), + ) + + fun createAccountReportsAndDraftsFilter(authorsHexes: List): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + DraftEvent.KIND, + ReportEvent.KIND, + BookmarkListEvent.KIND, + ), + authors = authorsHexes, + since = latestEOSE.relayList, + ), + ) + + fun createGiftWrapsToMeFilter(authorsHexes: List) = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to authorsHexes), + since = + latestEOSE.relayList.mapValues { + EOSETime(it.value.time - TimeUtils.twoDays()) + }, + ), + ) + + fun createNotificationFilter(authorsHexes: List) = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + TextNoteEvent.KIND, + PollNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + BadgeAwardEvent.KIND, + ), + tags = mapOf("p" to authorsHexes), + limit = 4000, + since = latestEOSE.relayList, + ), + ) + + fun createNotificationFilter2(authorsHexes: List) = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + GitReplyEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + HighlightEvent.KIND, + CommentEvent.KIND, + CalendarDateSlotEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ), + tags = mapOf("p" to authorsHexes), + limit = 400, + since = latestEOSE.relayList, + ), + ) + + fun mergeAllFilters( + mainAccounts: List, + otherAccounts: List, + ): List? = + if (hasLoadedTheBasics) { + // gets everything about the user logged in + listOfNotNull( + createAccountMetadataFilter(mainAccounts), + createAccountSettings2Filter(mainAccounts), + createNotificationFilter(mainAccounts), + createNotificationFilter2(mainAccounts), + createGiftWrapsToMeFilter(mainAccounts), + createAccountReportsAndDraftsFilter(mainAccounts), + createAccountSettingsFilter(mainAccounts), + createAccountLastPostsListFilter(mainAccounts), + createOtherAccountsBaseFilter(otherAccounts), + ).ifEmpty { null } + } else { + // just the basics. + listOf( + createAccountMetadataFilter(mainAccounts), + createAccountSettingsFilter(mainAccounts), + createAccountSettings2Filter(mainAccounts), + ).ifEmpty { null } + } + + val accountChannel = + requestNewSubscription { time, relayUrl -> + if (hasLoadedTheBasics) { + latestEOSE.addOrUpdate(relayUrl, time) + } else { + hasLoadedTheBasics = true + + invalidateFilters() + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val mainAccounts = mutableSetOf() + val otherAccounts = mutableSetOf() + + keys.forEach { + mainAccounts.add(it.account.userProfile().pubkeyHex) + otherAccounts.addAll(it.otherAccounts) + } + + accountChannel.typedFilters = mergeAllFilters(mainAccounts.toList(), (otherAccounts - mainAccounts).toList()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt new file mode 100644 index 000000000..c37727854 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun AccountFilterAssemblerSubscription(accountViewModel: AccountViewModel) = AccountFilterAssemblerSubscription(accountViewModel, Amethyst.instance.sources.account) + +@Composable +fun AccountFilterAssemblerSubscription( + accountViewModel: AccountViewModel, + dataSource: AccountFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel) { + AccountQueryState(accountViewModel.account, accountViewModel.allAccountsSync().toSet()) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssembler.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssembler.kt index 93894a463..680f1141a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssembler.kt @@ -18,23 +18,32 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import kotlin.collections.filter +import kotlin.collections.mapNotNull -object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() +// This allows multiple screen to be listening to tags, even the same tag +class ChannelFinderQueryState( + val channel: Channel, +) - private fun createMetadataChangeFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } +class ChannelFinderFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + private fun createMetadataChangeFilter(keys: Set): TypedFilter? { + val reactionsToWatch = keys.filter { it.channel is PublicChatChannel }.map { it.channel.idHex } if (reactionsToWatch.isEmpty()) { return null @@ -51,11 +60,11 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed ) } - fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { + fun createLoadEventsIfNotLoadedFilter(keys: Set): TypedFilter? { val directEventsToLoad = - channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel } + keys.filter { it.channel.notes.isEmpty() && it.channel is PublicChatChannel } - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + val interestedEvents = (directEventsToLoad).map { it.channel.idHex }.toSet() if (interestedEvents.isEmpty()) { return null @@ -72,11 +81,17 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed ) } - fun createLoadStreamingIfNotLoadedFilter(): List? { + fun createLoadStreamingIfNotLoadedFilter(keys: Set): List? { val directEventsToLoad = - channelsToWatch.filterIsInstance().filter { it.info == null } + keys.mapNotNull { + if (it.channel is LiveActivitiesChannel && it.channel.info == null) { + it.channel + } else { + null + } + } - val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() + val interestedEvents = directEventsToLoad.map { it.idHex }.toSet() if (interestedEvents.isEmpty()) { return null @@ -98,28 +113,13 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed } } - val singleChannelChannel = requestNewChannel() + val singleChannelChannel = requestNewSubscription() - override fun updateChannelFilters() { - val reactions = createMetadataChangeFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val missingStreaming = createLoadStreamingIfNotLoadedFilter() + override fun updateSubscriptions(keys: Set) { + val reactions = createMetadataChangeFilter(keys) + val missing = createLoadEventsIfNotLoadedFilter(keys) + val missingStreaming = createLoadStreamingIfNotLoadedFilter(keys) - singleChannelChannel.typedFilters = - ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } - } - - fun add(eventId: Channel) { - if (eventId !in channelsToWatch) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() - } - } - - fun remove(eventId: Channel) { - if (eventId in channelsToWatch) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() - } + singleChannelChannel.typedFilters = ((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblerSubscription.kt new file mode 100644 index 000000000..7f05d153b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblerSubscription.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun ChannelFinderFilterAssemblerSubscription(channel: Channel) = ChannelFinderFilterAssemblerSubscription(channel, Amethyst.instance.sources.channelFinder) + +@Composable +fun ChannelFinderFilterAssemblerSubscription( + channel: Channel, + dataSource: ChannelFinderFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(channel) { + ChannelFinderQueryState(channel) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt new file mode 100644 index 000000000..27325b39d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.livedata.observeAsState +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.ChannelState + +@Composable +fun observeChannel(baseChannel: Channel): State { + ChannelFinderFilterAssemblerSubscription(baseChannel) + + return baseChannel.live.observeAsState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 7ba8259da..a8cf30741 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -18,12 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.EOSETime import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter @@ -43,26 +46,22 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import kotlin.collections.mapNotNullTo -object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { - private var nextEventsToWatch = setOf() - private var nextAddressesToWatch = setOf() +// This allows multiple screen to be listening to tags, even the same tag +class EventFinderQueryState( + val note: Note, +) - private var eventsToWatchInProd = setOf() - private var addressesToWatchInProd = setOf() - - private fun createReactionsToWatchInAddressFilter(): List? { - val myAddressesToWatch = - ( - eventsToWatchInProd.filter { it.address() != null } + - addressesToWatchInProd.filter { it.address() != null } - ).toSet() - - if (myAddressesToWatch.isEmpty()) { +class EventFinderFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + private fun createReactionsToWatchInAddressFilter(keys: Set): List? { + if (keys.isEmpty()) { return null } - return groupByEOSEPresence(myAddressesToWatch) + return groupByEOSEPresence(keys) .map { listOf( TypedFilter( @@ -105,15 +104,15 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { }.flatten() } - private fun createAddressFilter(): List? { - val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null } + private fun createAddressFilter(keys: Set): List? { + val myAddressesToWatch = keys.filter { it.event == null } if (myAddressesToWatch.isEmpty()) { return null } - return myAddressesToWatch.mapNotNull { - it.address()?.let { aTag -> + return myAddressesToWatch.map { + it.address().let { aTag -> if (aTag.kind < 25000 && aTag.dTag.isBlank()) { TypedFilter( types = EVENT_FINDER_TYPES, @@ -140,12 +139,12 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { } } - private fun createRepliesAndReactionsFilter(): List? { - if (eventsToWatchInProd.isEmpty()) { + private fun createRepliesAndReactionsFilter(keys: Set): List? { + if (keys.isEmpty()) { return null } - return groupByEOSEPresence(eventsToWatchInProd) + return groupByEOSEPresence(keys) .map { listOf( TypedFilter( @@ -191,12 +190,8 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { }.flatten() } - private fun createQuotesFilter(): List? { - if (eventsToWatchInProd.isEmpty()) { - return null - } - - return groupByEOSEPresence(eventsToWatchInProd) + private fun createQuotesFilter(keys: Set): List? = + groupByEOSEPresence(keys) .map { listOf( TypedFilter( @@ -212,18 +207,17 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { ), ) }.flatten() - } - fun createLoadEventsIfNotLoadedFilter(): List? { - val directEventsToLoad = eventsToWatchInProd.filter { it.event == null } + fun createLoadEventsIfNotLoadedFilter(keys: Set): List? { + val directEventsToLoad = keys.filter { it.event == null } val threadingEventsToLoad = - eventsToWatchInProd + keys .mapNotNull { it.replyTo } .flatten() .filter { it !is AddressableNote && it.event == null } - val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet() + val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet().toList() if (interestedEvents.isEmpty()) { return null @@ -233,34 +227,22 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { return listOf( TypedFilter( types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - ids = interestedEvents.toList(), - ), + filter = SincePerRelayFilter(ids = interestedEvents), ), ) } val singleEventChannel = - requestNewChannel { time, relayUrl -> + requestNewSubscription { time, relayUrl -> // Ignores EOSE if it is in the middle of a filter change. - if (changingFilters.get()) return@requestNewChannel + if (isUpdatingFilters()) return@requestNewSubscription checkNotInMainThread() - eventsToWatchInProd.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] + forEachSubscriber { + val eose = it.note.lastReactionsDownloadTime[relayUrl] if (eose == null) { - it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time)) - } else { - eose.time = time - } - } - - addressesToWatchInProd.forEach { - val eose = it.lastReactionsDownloadTime[relayUrl] - if (eose == null) { - it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time)) + it.note.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time)) } else { eose.time = time } @@ -271,46 +253,33 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { invalidateFilters() } - override fun updateChannelFilters() { - addressesToWatchInProd = nextAddressesToWatch - eventsToWatchInProd = nextEventsToWatch + override fun updateSubscriptions(keys: Set) { + val addressables = + keys + .mapNotNullTo(mutableSetOf()) { + it.note as? AddressableNote + }.toSet() - val reactions = createRepliesAndReactionsFilter() - val missing = createLoadEventsIfNotLoadedFilter() - val addresses = createAddressFilter() - val addressReactions = createReactionsToWatchInAddressFilter() - val quotes = createQuotesFilter() + val events = + keys + .mapNotNullTo(mutableSetOf()) { + if (it.note !is AddressableNote) { + it.note + } else { + null + } + }.toSet() + + val eventReactions = createRepliesAndReactionsFilter(events) + val addressReactions = createReactionsToWatchInAddressFilter(addressables) + + val missingEvents = createLoadEventsIfNotLoadedFilter(events) + val missingAddresses = createAddressFilter(addressables) + + val quotes = createQuotesFilter(events + addressables) singleEventChannel.typedFilters = - listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null } - } - - fun add(eventId: Note) { - if (!nextEventsToWatch.contains(eventId)) { - nextEventsToWatch = nextEventsToWatch.plus(eventId) - invalidateFilters() - } - } - - fun remove(eventId: Note) { - if (nextEventsToWatch.contains(eventId)) { - nextEventsToWatch = nextEventsToWatch.minus(eventId) - invalidateFilters() - } - } - - fun addAddress(addressableNote: Note) { - if (!nextAddressesToWatch.contains(addressableNote)) { - nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote) - invalidateFilters() - } - } - - fun removeAddress(addressableNote: Note) { - if (nextAddressesToWatch.contains(addressableNote)) { - nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote) - invalidateFilters() - } + listOfNotNull(missingEvents, missingAddresses, eventReactions, addressReactions, quotes).flatten().ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt new file mode 100644 index 000000000..a515f8c27 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun EventFinderFilterAssemblerSubscription(note: Note) = EventFinderFilterAssemblerSubscription(note, Amethyst.instance.sources.eventFinder) + +@Composable +fun EventFinderFilterAssemblerSubscription( + note: Note, + dataSource: EventFinderFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(note) { + EventFinderQueryState(note) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt new file mode 100644 index 000000000..400e488bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -0,0 +1,239 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.livedata.observeAsState +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.combineWith +import com.vitorpamplona.quartz.nip01Core.core.Event + +@Composable +fun observeNote(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().metadata.observeAsState() +} + +@Composable +fun observeNoteEvent(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .metadata + .map { it.note.event as? T? } + .observeAsState(note.event as? T?) +} + +@Composable +fun observeNoteAndMap( + note: Note, + map: (Note) -> T, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .metadata + .map { map(it.note) } + .distinctUntilChanged() + .observeAsState(map(note)) +} + +@Composable +fun observeNoteEventAndMap( + note: Note, + map: (T) -> U, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .metadata + .map { (it.note.event as? T)?.let { map(it) } } + .distinctUntilChanged() + .observeAsState( + (note.event as? T)?.let { map(it) }, + ) +} + +@Composable +fun observeNoteHasEvent(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .metadata + .map { it.note.event != null } + .distinctUntilChanged() + .observeAsState(note.event != null) +} + +@Composable +fun observeNoteReplies(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().replies.observeAsState() +} + +@Composable +fun observeNoteReplyCount(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .replies + .map { it.note.reactions.size } + .distinctUntilChanged() + .observeAsState(note.reactions.size) +} + +@Composable +fun observeNoteReactions(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().reactions.observeAsState() +} + +@Composable +fun observeNoteReactionCount(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .reactions + .map { + var total = 0 + it.note.reactions.forEach { total += it.value.size } + total + }.distinctUntilChanged() + .observeAsState(0) +} + +@Composable +fun observeNoteZaps(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().zaps.observeAsState() +} + +@Composable +fun observeNoteReposts(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().boosts.observeAsState() +} + +@Composable +fun observeNoteRepostsBy( + note: Note, + user: User, +): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .boosts + .map { it.note.isBoostedBy(user) } + .distinctUntilChanged() + .observeAsState(note.isBoostedBy(user)) +} + +@Composable +fun observeNoteRepostCount(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .boosts + .map { it.note.boosts.size } + .distinctUntilChanged() + .observeAsState(note.boosts.size) +} + +@Composable +fun observeNoteReferences(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note + .live() + .zaps + .combineWith(note.live().boosts, note.live().reactions) { zapState, boostState, reactionState -> + zapState?.note?.zaps?.isNotEmpty() == true || + boostState?.note?.boosts?.isNotEmpty() == true || + reactionState?.note?.reactions?.isNotEmpty() == true + }.distinctUntilChanged() + .observeAsState( + note.zaps.isNotEmpty() || note.boosts.isNotEmpty() || note.reactions.isNotEmpty(), + ) +} + +@Composable +fun observeNoteOts(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().ots.observeAsState() +} + +@Composable +fun observeNoteEdits(note: Note): State { + // Subscribe in the relay for changes in this note. + EventFinderFilterAssemblerSubscription(note) + + // Subscribe in the LocalCache for changes that arrive in the device + return note.live().edits.observeAsState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index 5ce56efb2..d740a4bea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -18,45 +18,44 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent -class NostrLnZapPaymentResponseDataSource( - private val fromServiceHex: String, - private val toUserHex: String, - private val replyingToHex: String, - private val authSigner: NostrSigner, -) : AmethystNostrDataSource("LnZapPaymentResponseFeed") { - val feedTypes = setOf(FeedType.WALLET_CONNECT) +// This allows multiple screen to be listening to tags, even the same tag +class NWCPaymentQueryState( + val fromServiceHex: String, + val toUserHex: String, + val replyingToHex: String, +) - private fun createWalletConnectServiceWatcher(): TypedFilter { - // downloads all the reactions to a given event. - return TypedFilter( - types = feedTypes, +class NWCPaymentFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + fun createAccountMetadataFilter(keys: Set): TypedFilter = + TypedFilter( + types = setOf(FeedType.WALLET_CONNECT), filter = SincePerRelayFilter( kinds = listOf(LnZapPaymentResponseEvent.KIND), - authors = listOf(fromServiceHex), + authors = keys.map { it.fromServiceHex }, tags = mapOf( - "e" to listOf(replyingToHex), - "p" to listOf(toUserHex), + "e" to keys.map { it.replyingToHex }, + "p" to keys.map { it.toUserHex }, ), limit = 1, ), ) - } - val channel = requestNewChannel() + val channel = requestNewSubscription() - override fun updateChannelFilters() { - val wc = createWalletConnectServiceWatcher() - - channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + override fun updateSubscriptions(keys: Set) { + channel.typedFilters = listOf(createAccountMetadataFilter(keys)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt similarity index 64% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 8e0f28c1b..b667da3e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -18,10 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.findMinimumEOSEsForUsers +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.groupByEOSEPresence import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.EOSETime import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter @@ -32,13 +36,18 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { - private var usersToWatch = setOf() +// This allows multiple screen to be listening to tags, even the same tag +class UserFinderQueryState( + val user: User, +) - fun createUserMetadataFilter(): List? { - if (usersToWatch.isEmpty()) return null +class UserFinderFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + fun createUserMetadataFilter(keys: Set): List? { + if (keys.isEmpty()) return null - val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex } + val firstTimers = keys.filter { it.user.latestMetadata == null }.map { it.user.pubkeyHex }.distinct() if (firstTimers.isEmpty()) return null @@ -54,10 +63,10 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { ) } - fun createUserMetadataStatusReportFilter(): List? { - if (usersToWatch.isEmpty()) return null + fun createUserMetadataStatusReportFilter(keys: Set): List? { + if (keys.isEmpty()) return null - val secondTimers = usersToWatch.filter { it.latestMetadata != null } + val secondTimers = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet() if (secondTimers.isEmpty()) return null @@ -72,7 +81,14 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { types = EVENT_FINDER_TYPES, filter = SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, RelationshipStatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND), + kinds = + listOf( + MetadataEvent.KIND, + StatusEvent.KIND, + RelationshipStatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + ), authors = groupIds, since = minEOSEs, ), @@ -94,41 +110,23 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") { } val userChannel = - requestNewChannel { time, relayUrl -> - checkNotInMainThread() - - usersToWatch.forEach { - val eose = it.latestEOSEs[relayUrl] + requestNewSubscription { time, relayUrl -> + forEachSubscriber { + val eose = it.user.latestEOSEs[relayUrl] if (eose == null) { - it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time)) + it.user.latestEOSEs = it.user.latestEOSEs + Pair(relayUrl, EOSETime(time)) } else { eose.time = time } } } - override fun updateChannelFilters() { - checkNotInMainThread() - + override fun updateSubscriptions(keys: Set) { userChannel.typedFilters = listOfNotNull( - createUserMetadataFilter(), - createUserMetadataStatusReportFilter(), + createUserMetadataFilter(keys), + createUserMetadataStatusReportFilter(keys), ).flatten() .ifEmpty { null } } - - fun add(user: User) { - if (!usersToWatch.contains(user)) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() - } - } - - fun remove(user: User) { - if (usersToWatch.contains(user)) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() - } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt new file mode 100644 index 000000000..50feb6dbb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun UserFinderFilterAssemblerSubscription(user: User) = UserFinderFilterAssemblerSubscription(user, Amethyst.instance.sources.userFinder) + +@Composable +fun UserFinderFilterAssemblerSubscription( + user: User, + dataSource: UserFinderFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(user) { + UserFinderQueryState(user) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt new file mode 100644 index 000000000..75d9ec3e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.livedata.observeAsState +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata + +@Composable +fun observeUser(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user.live().metadata.observeAsState() +} + +@Composable +fun observeUserName(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.toBestDisplayName() } + .distinctUntilChanged() + .observeAsState(user.toBestDisplayName()) +} + +@Composable +fun observeUserNip05(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.info?.nip05 } + .distinctUntilChanged() + .observeAsState(user.info?.nip05) +} + +@Composable +fun observeUserAboutMe(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.info?.about ?: "" } + .distinctUntilChanged() + .observeAsState(user.info?.about ?: "") +} + +@Composable +fun observeUserInfo(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.info } + .distinctUntilChanged() + .observeAsState(user.info) +} + +@Composable +fun observeUserBanner(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.info?.banner ?: "" } + .distinctUntilChanged() + .observeAsState(user.info?.banner ?: "") +} + +@Composable +fun observeUserPicture(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.info?.picture } + .distinctUntilChanged() + .observeAsState(user.info?.picture) +} + +@Composable +fun observeUserShortName(user: User): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user) + + // Subscribe in the LocalCache for changes that arrive in the device + return user + .live() + .metadata + .map { it.user.toBestShortFirstName() } + .distinctUntilChanged() + .observeAsState(user.toBestShortFirstName()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/MutableQueryBasedSubscriptionOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/MutableQueryBasedSubscriptionOrchestrator.kt new file mode 100644 index 000000000..5db17d5da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/MutableQueryBasedSubscriptionOrchestrator.kt @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import android.util.Log +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +interface MutableQueryState { + fun flow(): Flow<*> +} + +/** + * Creates a data source where the filters are derived from + * data (keys) that is being watched in the UI. + */ +abstract class MutableQueryBasedSubscriptionOrchestrator( + client: NostrClient, + val scope: CoroutineScope, +) : SubscriptionOrchestrator(client) { + private var queries: ConcurrentHashMap = ConcurrentHashMap() + + // This is called by main. Keep it really fast. + fun subscribe(query: T?) { + if (query == null) return + + val wasEmpty = queries.isEmpty() + + queries[query]?.cancel() + queries[query] = + scope.launch { + query.flow().collectLatest { + invalidateFilters() + } + } + + if (wasEmpty) { + start() + } + + invalidateFilters() + + if (isDebug) { + Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)") + } + } + + // This is called by main. Keep it really fast. + fun unsubscribe(query: T?) { + if (query == null) return + + queries[query]?.cancel() + queries.remove(query) + + invalidateFilters() + + if (queries.isEmpty()) { + stop() + } + + if (isDebug) { + Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)") + } + } + + fun forEachSubscriber(action: (T) -> Unit) { + queries.keys.forEach(action) + } + + final override fun updateSubscriptions() { + updateSubscriptions(queries.keys) + } + + abstract fun updateSubscriptions(keys: Set) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt new file mode 100644 index 000000000..696c270ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.logTime +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES +import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip19Bech32.entities.Note +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.collections.flatten + +@Stable +class SearchQueryState( + val searchQuery: MutableStateFlow, +) : MutableQueryState { + override fun flow(): Flow = searchQuery +} + +class SearchFilterAssembler( + val cache: LocalCache, + client: NostrClient, + scope: CoroutineScope, +) : MutableQueryBasedSubscriptionOrchestrator(client, scope) { + fun filterByAuthor(pubKey: HexKey) = + listOf( + TypedFilter( + types = ALL_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOfNotNull(pubKey), + limit = 1, + ), + ), + ) + + fun filterByEvent(eventId: HexKey) = + listOf( + TypedFilter( + types = ALL_FEED_TYPES, + filter = + SincePerRelayFilter( + ids = listOfNotNull(eventId), + ), + ), + ) + + fun filterByAddress(parsed: NAddress) = + listOf( + TypedFilter( + types = ALL_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = listOf(MetadataEvent.KIND), + authors = listOfNotNull(parsed.author), + limit = 1, + ), + ), + TypedFilter( + types = ALL_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = listOf(parsed.kind), + authors = listOfNotNull(parsed.author), + tags = mapOf("d" to listOf(parsed.dTag)), + limit = 5, + ), + ), + ) + + private fun createAnythingWithIDFilter(keys: Set): List { + if (keys.isEmpty()) return emptyList() + + val uniqueQueries = + keys.mapNotNullTo(mutableSetOf()) { + if (!it.searchQuery.value.isBlank()) { + it.searchQuery.value + } else { + null + } + } + + return uniqueQueries + .map { mySearchString -> + val directFilters = + runCatching { + if (Hex.isHex(mySearchString)) { + val key = Hex.decode(mySearchString).toHexKey() + filterByAuthor(key) + filterByEvent(key) + } else { + when (val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity) { + is NSec -> filterByAuthor(Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey()) + is NPub -> filterByAuthor(parsed.hex) + is NProfile -> filterByAuthor(parsed.hex) + is Note -> filterByEvent(parsed.hex) + is NEvent -> filterByEvent(parsed.hex) + is NEmbed -> { + cache.verifyAndConsume(parsed.event, null) + emptyList() + } + + is NRelay -> emptyList() + is NAddress -> filterByAddress(parsed) + else -> emptyList() + } + } + }.getOrDefault(emptyList()) + + val searchFilters = + listOfNotNull( + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = listOf(MetadataEvent.KIND), + search = mySearchString, + limit = 1000, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PollNoteEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + TypedFilter( + types = setOf(FeedType.SEARCH), + filter = + SincePerRelayFilter( + kinds = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ), + search = mySearchString, + limit = 100, + ), + ), + ) + + directFilters + searchFilters + }.flatten() + } + + val searchChannel = requestNewSubscription() + + override fun updateSubscriptions(keys: Set) { + searchChannel.typedFilters = + logTime( + debugMessage = { "Search DataSource UpdateSubscriptions with ${it?.size} filter size" }, + block = { createAnythingWithIDFilter(keys).ifEmpty { null } }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt new file mode 100644 index 000000000..2cac3ea17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel + +@Composable +fun TextSearchDataSourceSubscription(searchBarViewModel: SearchBarViewModel) = TextSearchDataSourceSubscription(searchBarViewModel, Amethyst.instance.sources.search) + +@Composable +fun TextSearchDataSourceSubscription( + searchBarViewModel: SearchBarViewModel, + dataSource: SearchFilterAssembler, +) { + KeyDataSourceSubscription(searchBarViewModel.searchDataSourceState, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt new file mode 100644 index 000000000..40d2d1bcb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.searchCommand + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState + +@Composable +fun UserSearchDataSourceSubscription(userSuggestions: UserSuggestionState) = UserSearchDataSourceSubscription(userSuggestions, Amethyst.instance.sources.search) + +@Composable +fun UserSearchDataSourceSubscription( + userSuggestions: UserSuggestionState, + dataSource: SearchFilterAssembler, +) { + KeyDataSourceSubscription(userSuggestions.searchDataSourceState, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 9ea808ca8..7a6333d09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -102,7 +102,10 @@ class MainActivity : AppCompatActivity() { GlobalScope.launch(Dispatchers.IO) { LanguageTranslatorService.clear() } - GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } + GlobalScope.launch(Dispatchers.IO) { + debugState(this@MainActivity) + Amethyst.instance.sources.printCounters() + } super.onPause() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 21438eb16..e031b6115 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -54,7 +54,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -84,7 +83,6 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -135,15 +133,6 @@ fun EditPostView( postViewModel.load(edit, versionLookingAt, accountViewModel) } - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - Dialog( onDismissRequest = { onClose() }, properties = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 8489f7866..96ecc1ed3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -251,8 +250,6 @@ open class EditPostViewModel : ViewModel() { userSuggestions?.reset() userSuggestionsMainMessage = null - - NostrSearchEventOrUserDataSource.clear() } open fun findUrlInMessage(): String? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt deleted file mode 100644 index 300ec6164..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName -import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun JoinUserOrChannelView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val searchBarViewModel: SearchBarViewModel = - viewModel( - key = "SearchBarViewModel", - factory = - SearchBarViewModel.Factory( - accountViewModel.account, - ), - ) - - JoinUserOrChannelView( - searchBarViewModel = searchBarViewModel, - onClose = onClose, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun JoinUserOrChannelView( - searchBarViewModel: SearchBarViewModel, - onClose: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - Dialog( - onDismissRequest = { - NostrSearchEventOrUserDataSource.clear() - searchBarViewModel.clear() - onClose() - }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = - Modifier - .padding(10.dp) - .heightIn(min = 500.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - onClose() - }, - ) - - Text( - text = stringRes(R.string.channel_list_join_conversation), - fontWeight = FontWeight.Bold, - ) - - Text( - text = "", - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - RenderSearch(searchBarViewModel, accountViewModel, nav) - } - } - } -} - -@Composable -private fun RenderSearch( - searchBarViewModel: SearchBarViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val listState = rememberLazyListState() - - val lifeCycleOwner = LocalLifecycleOwner.current - - // Create a channel for processing search queries. - val searchTextChanges = remember { Channel(Channel.CONFLATED) } - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } - } - } - } - - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } - - searchBarViewModel.invalidateData() - - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } - } - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Join Start") - NostrSearchEventOrUserDataSource.start() - searchBarViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Join Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - // LAST ROW - SearchEditTextForJoin(searchBarViewModel, searchTextChanges) - - RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun SearchEditTextForJoin( - searchBarViewModel: SearchBarViewModel, - searchTextChanges: Channel, -) { - val scope = rememberCoroutineScope() - - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - LaunchedEffect(Unit) { - launch { - delay(100) - focusRequester.requestFocus() - } - } - - Row( - modifier = - Modifier - .padding(horizontal = 10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.channel_list_user_or_group_id)) }, - value = searchBarViewModel.searchValue, - onValueChange = { - searchBarViewModel.updateSearchValue(it) - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } - }, - leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) }, - modifier = - Modifier - .weight(1f, true) - .defaultMinSize(minHeight = 20.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringRes(R.string.channel_list_user_or_group_id_demo), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - if (searchBarViewModel.isSearching) { - IconButton( - onClick = { - searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() - }, - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringRes(R.string.clear), - ) - } - } - }, - ) - } -} - -@Composable -private fun RenderSearchResults( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (searchBarViewModel.isSearching) { - val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() - val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle() - - Row( - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(vertical = 10.dp), - ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, - state = listState, - ) { - itemsIndexed( - users, - key = { _, item -> "u" + item.pubkeyHex }, - ) { _, item -> - UserComposeForChat(item, accountViewModel) { - accountViewModel.createChatRoomFor(item) { nav.nav(Route.Room(it)) } - - searchBarViewModel.clear() - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } - - itemsIndexed( - channels, - key = { _, item -> "c" + item.idHex }, - ) { _, item -> - RenderChannel( - item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - ) { - nav.nav(Route.Channel(item.idHex)) - searchBarViewModel.clear() - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } -} - -@Composable -private fun RenderChannel( - item: com.vitorpamplona.amethyst.model.Channel, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onClick: () -> Unit, -) { - ChannelName( - channelIdHex = item.idHex, - channelPicture = item.profilePicture(), - channelTitle = { - Text( - item.toBestDisplayName(), - fontWeight = FontWeight.Bold, - ) - }, - channelLastTime = null, - channelLastContent = item.summary(), - hasNewMessages = false, - onClick = onClick, - loadProfilePicture = loadProfilePicture, - loadRobohash = loadRobohash, - ) -} - -@Composable -fun UserComposeForChat( - baseUser: User, - accountViewModel: AccountViewModel, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .clickable( - onClick = onClick, - ).padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - ClickableUserPicture(baseUser, Size55dp, accountViewModel) - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } - - DisplayUserAboutInfo(baseUser) - } - } -} - -@Composable -private fun DisplayUserAboutInfo(baseUser: User) { - val baseUserState by baseUser.live().metadata.observeAsState() - val about by remember(baseUserState) { derivedStateOf { baseUserState?.user?.info?.about ?: "" } } - - Text( - text = about, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 76fd0075a..6e2075a7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.actions -import android.R.attr.category import android.content.Context import android.util.Log import androidx.compose.runtime.Stable @@ -45,7 +44,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator @@ -1106,8 +1104,6 @@ open class NewPostViewModel : emojiSuggestions?.reset() draftTag = UUID.randomUUID().toString() - - NostrSearchEventOrUserDataSource.clear() } fun deleteDraft() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 8214257d4..5a9a65de3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -58,6 +57,9 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.routeFor @@ -190,8 +192,7 @@ private fun DisplayNoteLink( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by it.live().metadata.observeAsState() - + val noteState by observeNote(it) val note = remember(noteState) { noteState?.note } ?: return val channelHex = remember(noteState) { note.channelHex() } @@ -214,7 +215,7 @@ private fun DisplayNoteLink( ) } else if (channelHex != null) { LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel -> - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel) val channelDisplayName by remember(channelState) { derivedStateOf { channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote } @@ -254,7 +255,7 @@ private fun DisplayAddress( } noteBase?.let { - val noteState by it.live().metadata.observeAsState() + val noteState by observeNote(it) val route = remember(noteState) { Route.Note(nip19.aTag()) } val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } @@ -324,7 +325,7 @@ public fun RenderUserAsClickableText( additionalChars: String?, nav: INav, ) { - val userState by baseUser.live().userMetadataInfo.observeAsState() + val userState by observeUserInfo(baseUser) CreateClickableTextWithEmoji( clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index b87492a5c..f1d191a36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -86,6 +85,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.EmptyNav @@ -777,7 +777,7 @@ private fun DisplayUserFromTag( accountViewModel: AccountViewModel, nav: INav, ) { - val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val meta by observeUserInfo(baseUser) CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 2f31da4e8..33365c39d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -39,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.DisplayFullNote import com.vitorpamplona.amethyst.ui.components.DisplayUser import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview @@ -231,7 +231,7 @@ class MarkdownMediaRenderer( ) { renderInvisible(richTextStringBuilder) { // Preloads note if not loaded yet. - baseNote.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(baseNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt new file mode 100644 index 000000000..43c42d7bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveFeedFilter.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.dal + +import kotlin.time.measureTimedValue + +abstract class AdditiveFeedFilter : FeedFilter() { + abstract fun applyFilter(collection: Set): Set + + abstract fun sort(collection: Set): List + + open fun updateListWith( + oldList: List, + newItems: Set, + ): List { + val (feed, elapsed) = + measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(limit()) + } else { + oldList + } + } + + // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} + // objects") + return feed + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 0bb8ca70a..acd30f54b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -21,13 +21,10 @@ package com.vitorpamplona.amethyst.ui.dal import android.util.Log -import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlin.time.measureTimedValue abstract class FeedFilter { fun loadTop(): List { - checkNotInMainThread() - val (feed, elapsed) = measureTimedValue { feed() } Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects") @@ -43,31 +40,3 @@ abstract class FeedFilter { abstract fun feed(): List } - -abstract class AdditiveFeedFilter : FeedFilter() { - abstract fun applyFilter(collection: Set): Set - - abstract fun sort(collection: Set): List - - open fun updateListWith( - oldList: List, - newItems: Set, - ): List { - checkNotInMainThread() - - val (feed, elapsed) = - measureTimedValue { - val newItemsToBeAdded = applyFilter(newItems) - if (newItemsToBeAdded.isNotEmpty()) { - val newList = oldList.toSet() + newItemsToBeAdded - sort(newList).take(limit()) - } else { - oldList - } - } - - // Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} - // objects") - return feed - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt index 8a0f4a610..e3aa1ae87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/InvalidatableContent.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.feeds -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State interface InvalidatableContent { fun invalidateData(ignoreIfDoing: Boolean = false) - val isRefreshing: MutableState + val isRefreshing: State } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt new file mode 100644 index 000000000..f71a62965 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchLifecycleAndUpdateModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.feeds + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LifecycleResumeEffect + +@Composable +fun WatchLifecycleAndUpdateModel(model: InvalidatableContent) { + LaunchedEffect(model) { + model.invalidateData(true) + } + + LifecycleResumeEffect(model) { + model.invalidateData(true) + onPauseOrDispose {} + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 1800e7c77..0e22b2ff6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.vitorpamplona.amethyst.model.BooleanType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlin.math.abs diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index b43c3ba47..06e8d16f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -61,6 +60,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -214,7 +215,7 @@ private fun AccountPicture( loadProfilePicture: Boolean, loadRobohash: Boolean, ) { - val profilePicture by user.live().profilePictureChanges.observeAsState() + val profilePicture by observeUserPicture(user) RobohashFallbackAsyncImage( robot = user.pubkeyHex, @@ -231,7 +232,7 @@ private fun AccountName( acc: AccountInfo, user: User, ) { - val info by user.live().userMetadataInfo.observeAsState() + val info by observeUserInfo(user) info?.let { it.bestName()?.let { name -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a847d1c41..e4a7342a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -46,15 +46,14 @@ import androidx.core.util.Consumer import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen -import com.vitorpamplona.amethyst.ui.components.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen @@ -72,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index c8fdbba02..ac2fdbe5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -29,13 +29,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.FeedDefinition @@ -89,12 +89,7 @@ private fun LoggedInUserPictureDrawer( onClick: () -> Unit, ) { IconButton(onClick = onClick) { - val profilePicture by - accountViewModel.account - .userProfile() - .live() - .profilePictureChanges - .observeAsState() + val profilePicture by observeUserPicture(accountViewModel.userProfile()) RobohashFallbackAsyncImage( robot = accountViewModel.userProfile().pubkeyHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index e28ff8840..8db52a3e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation -import android.R.attr.maxLines -import android.R.attr.onClick import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -92,12 +90,14 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.LoadStatuses -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -183,7 +183,7 @@ fun ProfileContent( accountViewModel: AccountViewModel, onClickUser: () -> Unit, ) { - val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState() + val userInfo by observeUserInfo(baseAccountUser) ProfileContentTemplate( profilePubHex = baseAccountUser.pubkeyHex, @@ -270,9 +270,9 @@ private fun EditStatusBoxes( StatusEditBar(accountViewModel = accountViewModel, nav = nav) } else { statuses.forEach { - val originalStatus by it.live().content.observeAsState() + val noteStatus by observeNote(it) - StatusEditBar(originalStatus, it.address, accountViewModel, nav) + StatusEditBar(noteStatus?.note?.event?.content, it.address, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt index bae4c9263..a798d379b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -48,7 +47,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.map import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -56,6 +54,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition @@ -251,11 +250,7 @@ fun RenderOption(option: Name) { horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { - val noteState by - option.note - .live() - .metadata - .observeAsState() + val noteState by observeNote(option.note) val name = (noteState?.note?.event as? PeopleListEvent)?.nameOrTitle() ?: option.note.dTag() ?: "" @@ -267,14 +262,9 @@ fun RenderOption(option: Name) { horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { - val name by - option.note - .live() - .metadata - .map { "/n/" + ((it.note as? AddressableNote)?.dTag() ?: "") } - .observeAsState() + val it by observeNote(option.note) - Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface) + Text(text = "/n/${((it?.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index ba81a7ba3..ff36927b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -21,10 +21,8 @@ package com.vitorpamplona.amethyst.ui.navigation import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index b43397112..b65a3510d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -36,13 +36,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton @@ -61,10 +61,7 @@ fun BadgeCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by likeSetCard.note - .live() - .metadata - .observeAsState() + val noteState by observeNote(likeSetCard.note) val note = noteState?.note val context = LocalContext.current.applicationContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 75c952aea..bf0319c15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -62,8 +61,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import com.vitorpamplona.amethyst.R @@ -72,6 +69,10 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout @@ -302,28 +303,16 @@ fun RenderClassifiedsThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? ClassifiedsEvent ?: return + if (baseNote.event !is ClassifiedsEvent) return - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? ClassifiedsEvent - - ClassifiedsThumb( - image = noteEvent?.image(), - title = noteEvent?.title(), - price = noteEvent?.price(), - ) - }.distinctUntilChanged() - .observeAsState( - ClassifiedsThumb( - image = noteEvent.image(), - title = noteEvent.title(), - price = noteEvent.price(), - ), - ) + val card by observeNoteAndMap(baseNote) { + val noteEvent = it.event as? ClassifiedsEvent + ClassifiedsThumb( + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price(), + ) + } InnerRenderClassifiedsThumb(card, baseNote) } @@ -425,39 +414,36 @@ fun RenderLiveActivityThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + val card by observeNoteAndMap(baseNote) { + val noteEvent = it.event as? LiveActivitiesEvent - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? LiveActivitiesEvent + LiveActivityCard( + name = noteEvent?.dTag() ?: "", + cover = noteEvent?.image()?.ifBlank { null }, + media = noteEvent?.streaming(), + subject = noteEvent?.title()?.ifBlank { null }, + content = noteEvent?.summary(), + participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), + status = noteEvent?.status(), + starts = noteEvent?.starts(), + ) + } - LiveActivityCard( - name = noteEvent?.dTag() ?: "", - cover = noteEvent?.image()?.ifBlank { null }, - media = noteEvent?.streaming(), - subject = noteEvent?.title()?.ifBlank { null }, - content = noteEvent?.summary(), - participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(), - status = noteEvent?.status(), - starts = noteEvent?.starts(), - ) - }.distinctUntilChanged() - .observeAsState( - LiveActivityCard( - name = noteEvent.dTag(), - cover = noteEvent.image()?.ifBlank { null }, - media = noteEvent.streaming(), - subject = noteEvent.title()?.ifBlank { null }, - content = noteEvent.summary(), - participants = noteEvent.participants().toImmutableList(), - status = noteEvent.status(), - starts = noteEvent.starts(), - ), - ) + RenderLiveActivityThumb( + card, + baseNote, + accountViewModel, + nav, + ) +} +@Composable +fun RenderLiveActivityThumb( + card: LiveActivityCard, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { Column( modifier = Modifier.fillMaxWidth(), ) { @@ -560,31 +546,29 @@ fun RenderCommunitiesThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return + val noteState by observeNote(baseNote) + val noteEvent = noteState?.note?.event as? CommunityDefinitionEvent ?: return - val card by - baseNote - .live() - .metadata - .map { - val noteEvent = it.note.event as? CommunityDefinitionEvent - - CommunityCard( - name = noteEvent?.dTag() ?: "", - description = noteEvent?.description(), - cover = noteEvent?.image()?.imageUrl, - moderators = noteEvent?.moderatorKeys()?.toImmutableList() ?: persistentListOf(), - ) - }.distinctUntilChanged() - .observeAsState( - CommunityCard( - name = noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.imageUrl, - moderators = noteEvent.moderatorKeys().toImmutableList(), - ), - ) + RenderCommunitiesThumb( + CommunityCard( + name = noteEvent.dTag(), + description = noteEvent.description(), + cover = noteEvent.image()?.imageUrl, + moderators = noteEvent.moderatorKeys().toImmutableList(), + ), + baseNote, + accountViewModel, + nav, + ) +} +@Composable +fun RenderCommunitiesThumb( + card: CommunityCard, + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { LeftPictureLayout( onImage = { card.cover?.let { @@ -767,12 +751,7 @@ fun RenderContentDVMThumb( nav: INav, ) { // downloads user metadata to pre-load the NIP-65 relays. - val user = - baseNote.author - ?.live() - ?.metadata - ?.observeAsState() - + baseNote.author?.let { UserFinderFilterAssemblerSubscription(it) } val card = observeAppDefinition(appDefinitionNote = baseNote) LeftPictureLayout( @@ -791,7 +770,7 @@ fun RenderContentDVMThumb( ) } } ?: run { - user?.value?.user?.let { + baseNote.author?.let { BannerImage( it, Modifier @@ -930,7 +909,7 @@ fun RenderChannelThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val channelUpdates by channel.live.observeAsState() + val channelUpdates by observeChannel(channel) val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" } val description = remember(channelUpdates) { channelUpdates?.channel?.summary()?.ifBlank { null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 6c9b65488..09117af87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -162,7 +163,7 @@ fun LoadOts( ) { var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } - val noteStatus by note.live().innerOts.observeAsState() + val noteStatus by observeNoteOts(note) LaunchedEffect(key1 = noteStatus) { accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 2ed435160..c47fd79b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer @@ -623,7 +623,7 @@ private fun WatchUserMetadata( author: User, onNewMetadata: @Composable (String?) -> Unit, ) { - val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture()) + val userProfile by observeUserPicture(author) onNewMetadata(userProfile) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index ed7ac1dc8..db1716ad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,6 +51,9 @@ import com.vitorpamplona.amethyst.commons.hashtags.Tunestr import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05 import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor @@ -133,7 +135,7 @@ fun ObserveDisplayNip05Status( accountViewModel: AccountViewModel, nav: INav, ) { - val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) + val nip05 by observeUserNip05(baseUser) LoadStatuses(baseUser, accountViewModel) { statuses -> CrossfadeIfEnabled( @@ -204,10 +206,9 @@ fun ObserveRotateStatuses( @Composable fun ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses: ImmutableList) { - statuses - .map { - it.live().metadata.observeAsState() - } + statuses.map { + EventFinderFilterAssemblerSubscription(it) + } } @Composable @@ -247,7 +248,7 @@ fun DisplayStatus( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by addressableNote.live().metadata.observeAsState() + val noteState by observeNote(addressableNote) val noteEvent = noteState?.note?.event as? StatusEvent ?: return DisplayStatus(noteEvent, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 97f38ff9b..28898672b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -52,12 +52,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -849,14 +853,14 @@ fun ObserveDraftEvent( accountViewModel: AccountViewModel, render: @Composable (Note) -> Unit, ) { - val noteState by note.live().metadata.observeAsState() + val noteEvent by observeNoteEvent(note) - val noteEvent = noteState?.note?.event as? DraftEvent ?: return + noteEvent?.let { + val innerNote by produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = it) - val innerNote = produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = noteEvent) - - innerNote.value?.let { - render(it) + innerNote?.let { + render(it) + } } } @@ -1109,7 +1113,7 @@ fun observeEdits( ) } - val updatedNote by baseNote.live().innerModifications.observeAsState() + val updatedNote by observeNoteEdits(baseNote) LaunchedEffect(key1 = updatedNote) { updatedNote?.note?.let { @@ -1186,6 +1190,8 @@ private fun ChannelNotePicture( loadProfilePicture: Boolean, loadRobohash: Boolean, ) { + ChannelFinderFilterAssemblerSubscription(baseChannel, Amethyst.instance.sources.channelFinder) + val model by baseChannel.live .map { it.channel.profilePicture() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index be90c1bdf..897d2e71f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -90,14 +90,13 @@ import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.WarningColor import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward -import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 725f74977..b50ffcc27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -51,7 +51,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -75,6 +74,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.toasts.StringToastMsg import com.vitorpamplona.amethyst.ui.navigation.EmptyNav @@ -283,7 +283,7 @@ private fun WatchZapsAndUpdateTallies( baseNote: Note, pollViewModel: PollNoteViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote) LaunchedEffect(key1 = zapsState) { pollViewModel.refreshTallies() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 047dc83e1..2d1f79e3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -65,7 +65,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -97,15 +96,20 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReferences +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReposts +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteRepostsBy +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -344,7 +348,7 @@ fun RenderZapRaiser( details: Boolean, accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote) var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } @@ -394,15 +398,7 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists( baseNote: Note, content: @Composable () -> Unit, ) { - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by observeNoteReferences(baseNote) if (hasReactions) { content() @@ -463,15 +459,7 @@ private fun ReactionDetailGallery( val defaultBackgroundColor = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } - val hasReactions by - baseNote - .live() - .hasReactions - .observeAsState( - baseNote.zaps.isNotEmpty() || - baseNote.boosts.isNotEmpty() || - baseNote.reactions.isNotEmpty(), - ) + val hasReactions by observeNoteReferences(baseNote) if (hasReactions) { Row( @@ -480,8 +468,8 @@ private fun ReactionDetailGallery( ) { Column { WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) - WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) - WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) + // WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) + // WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) } } } @@ -493,7 +481,7 @@ private fun WatchBoostsAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val boostsEvents by baseNote.live().boosts.observeAsState() + val boostsEvents by observeNoteReposts(baseNote) boostsEvents?.let { if (it.note.boosts.isNotEmpty()) { @@ -512,7 +500,7 @@ private fun WatchReactionsAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val reactionsState by baseNote.live().reactions.observeAsState() + val reactionsState by observeNoteReactions(baseNote) val reactionEvents = reactionsState?.note?.reactions ?: return if (reactionEvents.isNotEmpty()) { @@ -535,7 +523,7 @@ private fun WatchZapAndRenderGallery( nav: INav, accountViewModel: AccountViewModel, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote) var zapEvents by remember(zapsState) { @@ -692,7 +680,7 @@ fun ReplyCounter( textColor: Color, accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size) + val repliesState by observeNoteReplyCount(baseNote) SlidingAnimationCount(repliesState, textColor, accountViewModel) } @@ -827,16 +815,7 @@ fun ObserveBoostIcon( accountViewModel: AccountViewModel, inner: @Composable (Boolean) -> Unit, ) { - val hasBoosted by - remember(baseNote) { - baseNote - .live() - .boosts - .map { it.note.isBoostedBy(accountViewModel.userProfile()) } - .distinctUntilChanged() - }.observeAsState( - baseNote.isBoostedBy(accountViewModel.userProfile()), - ) + val hasBoosted by observeNoteRepostsBy(baseNote, accountViewModel.userProfile()) inner(hasBoosted) } @@ -847,7 +826,7 @@ fun BoostText( grayTint: Color, accountViewModel: AccountViewModel, ) { - val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size) + val boostState by observeNoteRepostCount(baseNote) SlidingAnimationCount(boostState, grayTint, accountViewModel) } @@ -917,7 +896,7 @@ fun ObserveLikeIcon( accountViewModel: AccountViewModel, inner: @Composable (String?) -> Unit, ) { - val reactionsState by baseNote.live().reactions.observeAsState() + val reactionsState by observeNoteReactions(baseNote) @Suppress("ProduceStateDoesNotAssignValue") val reactionType by @@ -974,7 +953,7 @@ fun ObserveLikeText( baseNote: Note, inner: @Composable (Int) -> Unit, ) { - val reactionCount by baseNote.live().reactionCount.observeAsState(0) + val reactionCount by observeNoteReactionCount(baseNote) inner(reactionCount) } @@ -1227,7 +1206,7 @@ fun ObserveZapIcon( val wasZappedByLoggedInUser = remember { mutableStateOf(false) } if (!wasZappedByLoggedInUser.value) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote) LaunchedEffect(key1 = zapsState) { if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) { @@ -1249,7 +1228,7 @@ fun ObserveZapAmountText( accountViewModel: AccountViewModel, inner: @Composable (String) -> Unit, ) { - val zapsState by baseNote.live().zaps.observeAsState() + val zapsState by observeNoteZaps(baseNote) if (zapsState?.note?.zapPayments?.isNotEmpty() == true) { @Suppress("ProduceStateDoesNotAssignValue") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index bf095f47a..592828410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -37,6 +36,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor @@ -120,7 +120,7 @@ private fun ReplyInfoMention( prefix: String, onUserTagClick: (User) -> Unit, ) { - val innerUserState by user.live().userMetadataInfo.observeAsState() + val innerUserState by observeUserInfo(user) CreateClickableTextWithEmoji( clickablePart = "$prefix${innerUserState?.bestName()}", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 93cbd2be3..c56249ce9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -55,7 +55,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -72,8 +71,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -81,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge @@ -387,17 +385,9 @@ private fun EmojiSelector( accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by - usersEmojiList - .live() - .metadata - .map { (it.note.event as? EmojiPackSelectionEvent)?.emojiPackIds()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.emojiPackIds() - ?.toImmutableList(), - ) + val collections by observeNoteEventAndMap(usersEmojiList) { event: EmojiPackSelectionEvent -> + event.emojiPackIds().toImmutableList() + } collections?.let { EmojiCollectionGallery(it, accountViewModel, nav, onClick) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index a47061d90..9fcd40a79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -92,7 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer -import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index a3972d12d..9fbdb3914 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,6 +39,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav @@ -328,7 +328,7 @@ fun LoadUserProfilePicture( baseUser: User, innerContent: @Composable (String?, String?) -> Unit, ) { - val userProfile by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userProfile by observeUserInfo(baseUser) innerContent(userProfile?.profilePicture(), userProfile?.bestName()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 6ba65b507..45f06c424 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.LifecycleOwner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -67,8 +68,7 @@ fun WatchAuthor( if (noteAuthor != null) { inner(noteAuthor) } else { - val authorState by baseNote.live().metadata.observeAsState() - + val authorState by observeNote(baseNote) authorState?.note?.author?.let { inner(it) } @@ -86,8 +86,7 @@ fun WatchAuthorWithBlank( if (noteAuthor != null) { inner(noteAuthor) } else { - val authorState by baseNote.live().metadata.observeAsState() - + val authorState by observeNote(baseNote) CrossfadeIfEnabled(targetState = authorState?.note?.author, modifier = modifier, label = "WatchAuthorWithBlank", accountViewModel = accountViewModel) { newAuthor -> inner(newAuthor) } @@ -102,7 +101,7 @@ fun UsernameDisplay( textColor: Color = Color.Unspecified, accountViewModel: AccountViewModel, ) { - val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info) + val userMetadata by observeUserInfo(baseUser) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { val name = it?.bestName() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index 1983a4ba3..5c47b0998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -24,10 +24,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -74,8 +74,7 @@ fun WatchNoteEvent( onNoteEventFound() } else { // avoid observing costs if already has an event. - - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by observeNoteHasEvent(baseNote) CrossfadeIfEnabled(targetState = hasEvent, label = "Event presence", accountViewModel = accountViewModel) { if (it) { onNoteEventFound() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index e7ed80066..ab2eda8e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -44,6 +43,8 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -65,10 +66,7 @@ fun ZapNoteCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val baseNoteRequest by baseReqResponse.zapRequest - .live() - .metadata - .observeAsState() + val baseNoteRequest by observeNote(baseReqResponse.zapRequest) var baseAuthor by remember { mutableStateOf(null) } @@ -135,7 +133,7 @@ private fun RenderZapNote( @Composable private fun ZapAmount(zapEventNote: Note) { - val noteState by zapEventNote.live().metadata.observeAsState() + val noteState by observeNote(zapEventNote) var zapAmount by remember { mutableStateOf(null) } @@ -221,12 +219,10 @@ fun ShowFollowingOrUnfollowingButton( @Composable fun AboutDisplay(baseAuthor: User) { - val baseAuthorState by baseAuthor.live().metadata.observeAsState() - val userAboutMe by - remember(baseAuthorState) { derivedStateOf { baseAuthorState?.user?.info?.about ?: "" } } + val aboutMe by observeUserAboutMe(baseAuthor) Text( - userAboutMe, + aboutMe, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt index 94ec784e3..ff1d0403b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt @@ -22,9 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses @@ -38,23 +37,15 @@ fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by usersEmojiList - .live() - .metadata - .map { - (it.note.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() - ?.toImmutableList() - }.distinctUntilChanged() - .observeAsState( - (usersEmojiList.event as? EmojiPackSelectionEvent) - ?.taggedAddresses() - ?.toImmutableList(), - ) + val collections by observeNoteEventAndMap(usersEmojiList) { event: EmojiPackSelectionEvent -> + event.taggedAddresses().toImmutableList() + } - collections?.forEach { - LoadAddressableNote(it, accountViewModel) { - it?.live()?.metadata?.observeAsState() + collections?.forEach { address -> + LoadAddressableNote(address, accountViewModel) { note -> + if (note != null) { + EventFinderFilterAssemblerSubscription(note) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index ae4e67e71..ba8dc7b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -28,15 +28,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -50,14 +55,50 @@ fun ShowUserSuggestionList( accountViewModel: AccountViewModel, modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), ) { - val userSuggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + UserSearchDataSourceSubscription(userSuggestions) - if (userSuggestions.isNotEmpty()) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + LaunchedEffect(Unit) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier) +} + +@Composable +fun AnimateOnNewSearch( + userSuggestions: UserSuggestionState, + listState: LazyListState, +) { + val searchTerm by userSuggestions.searchTerm.collectAsStateWithLifecycle("") + + LaunchedEffect(searchTerm) { + listState.animateScrollToItem(0) + } +} + +@Composable +fun WatchResponses( + userSuggestions: UserSuggestionState, + listState: LazyListState, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), +) { + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, + state = listState, ) { - itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { _, item -> + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> UserLine(item, accountViewModel) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 6dee26cd3..ebad5f11a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -22,43 +22,73 @@ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update class UserSuggestionState( val accountViewModel: AccountViewModel, ) { - var search = MutableStateFlow("") - var results = - search - .debounce(500) + val invalidations = MutableStateFlow(0) + val currentWord = MutableStateFlow("") + val searchDataSourceState = SearchQueryState(MutableStateFlow("")) + + @OptIn(FlowPreview::class) + val searchTerm = + currentWord + .debounce(300) .distinctUntilChanged() - .map { word -> - if (word.startsWith("@") && word.length > 2) { - val prefix = word.removePrefix("@") - NostrSearchEventOrUserDataSource.search(prefix) + .map(::userSearchTermOrNull) + .onEach(::updateDataSource) + + val results = + combine(searchTerm, invalidations.debounce(100)) { prefix, version -> + if (prefix != null) { + logTime("UserSuggestionState Search $prefix version $version") { accountViewModel.findUsersStartingWithSync(prefix) - } else { - NostrSearchEventOrUserDataSource.clear() - search.tryEmit("") - emptyList() } - }.flowOn(Dispatchers.IO) + } else { + emptyList() + } + }.flowOn(Dispatchers.Default) fun reset() { - NostrSearchEventOrUserDataSource.clear() - search.tryEmit("") + currentWord.tryEmit("") } fun processCurrentWord(word: String) { - search.tryEmit(word) + currentWord.tryEmit(word) + } + + fun invalidateData() { + // force new query + invalidations.update { it + 1 } + } + + fun userSearchTermOrNull(currentWord: String): String? = + if (currentWord.startsWith("@") && currentWord.length > 2) { + currentWord.removePrefix("@") + } else { + null + } + + fun updateDataSource(searchTerm: String?) { + if (searchTerm != null) { + searchDataSourceState.searchQuery.tryEmit(searchTerm) + } else { + searchDataSourceState.searchQuery.tryEmit("") + } } fun replaceCurrentWord( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index d81c829d5..43643d5f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -106,8 +106,7 @@ fun ObserveRelayListForDMs( accountViewModel, ) { relayList -> if (relayList != null) { - val relayListNoteState by relayList.live().metadata.observeAsState() - val relayListEvent = relayListNoteState?.note?.event as? ChatMessageRelayListEvent + val relayListEvent by observeNoteEvent(relayList) inner(relayListEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt index 90b4d65a0..8365ca5f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -36,6 +35,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,20 +64,21 @@ fun BannerImage( author: User, imageModifier: Modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp), ) { - val currentInfo by author.live().userMetadataInfo.observeAsState() - currentInfo?.banner?.let { + val banner by observeUserBanner(author) + + if (banner.isNotBlank()) { AsyncImage( - model = it, + model = banner, contentDescription = stringRes( R.string.preview_card_image_for, - it, + banner, ), contentScale = ContentScale.Crop, modifier = imageModifier, placeholder = painterResource(R.drawable.profile_banner), ) - } ?: run { + } else { Image( painter = painterResource(R.drawable.profile_banner), contentDescription = stringRes(R.string.profile_banner), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 8f44e07eb..3423514ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -58,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route @@ -115,7 +115,7 @@ private fun RenderPledgeAmount( baseReward: Reward, accountViewModel: AccountViewModel, ) { - val repliesState by baseNote.live().replies.observeAsState() + val repliesState by observeNoteReplies(baseNote) var reward by remember { mutableStateOf( showAmount(baseReward.amount), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 438b9ef45..aa4e9994c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 63a895ad8..22380b4d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -27,13 +27,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink @@ -80,7 +82,7 @@ fun ForkInformationRowLightColor( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by originalVersion.live().metadata.observeAsState() + val noteState by observeNote(originalVersion) val note = noteState?.note ?: return val author = note.author ?: return val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } @@ -103,20 +105,16 @@ fun ForkInformationRowLightColor( overflow = TextOverflow.Visible, ) - val userState by author.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.tags } - - if (userDisplayName != null) { + val userState by observeUser(author) + userState?.user?.toBestDisplayName()?.let { CreateClickableTextWithEmoji( - clickablePart = userDisplayName, + clickablePart = it, maxLines = 1, route = route, overrideColor = MaterialTheme.colorScheme.nip05, fontSize = Font14SP, nav = nav, - tags = userTags, + tags = userState?.user?.info?.tags, ) } } @@ -130,19 +128,19 @@ fun ForkInformationRow( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by originalVersion.live().metadata.observeAsState() + val noteState by observeNote(originalVersion) val note = noteState?.note ?: return val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } if (route != null) { Row(modifier) { val author = note.author ?: return - val meta by author.live().userMetadataInfo.observeAsState(author.info) + val meta by observeUserInfo(author) Text(stringRes(id = R.string.forked_from)) Spacer(modifier = StdHorzSpacer) - val userMetadata by author.live().userMetadataInfo.observeAsState() + val userMetadata by observeUserInfo(author) CreateClickableTextWithEmoji( clickablePart = remember(meta) { meta?.bestName() ?: author.pubkeyDisplayHex() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index 958434b97..53538a51a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.R.attr.onClick import android.content.Context import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement @@ -40,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,6 +63,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.appendLink @@ -189,7 +188,7 @@ fun ZapTheDevsCard( accountViewModel: AccountViewModel, nav: INav, ) { - val releaseNoteState by baseNote.live().metadata.observeAsState() + val releaseNoteState by observeNote(baseNote) val releaseNote = releaseNoteState?.note ?: return Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 366bd7946..dd76b4271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,6 +46,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -58,16 +58,17 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @Composable fun BadgeDisplay(baseNote: Note) { - val observingNote by baseNote.live().metadata.observeAsState() - val badgeData = observingNote?.note?.event as? BadgeDefinitionEvent ?: return + val badgeData by observeNoteEvent(baseNote) - RenderBadge( - badgeData.image(), - badgeData.name(), - MaterialTheme.colorScheme.background, - MaterialTheme.colorScheme.onBackground, - badgeData.description(), - ) + badgeData?.let { + RenderBadge( + it.image(), + it.name(), + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.onBackground, + it.description(), + ) + } } @Preview diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 588badc4a..a4dc22f31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -35,7 +35,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -52,6 +51,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.INav @@ -119,19 +119,17 @@ fun LongCommunityHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteEvent by observeNoteEvent(baseNote) Row( lineModifier, ) { val rulesLabel = stringRes(id = R.string.rules) val summary = - remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } + remember(noteEvent) { + val subject = noteEvent?.subject()?.ifEmpty { null } + val body = noteEvent?.description()?.ifBlank { null } + val rules = noteEvent?.rules()?.ifBlank { null } if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { if (rules == null) { @@ -167,12 +165,14 @@ fun LongCommunityHeader( ) } - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - event = noteEvent, - content = summary, - nav = nav, - ) + noteEvent?.let { + if (it.hasHashtags()) { + DisplayUncitedHashtags( + event = it, + content = summary ?: "", + nav = nav, + ) + } } } @@ -207,8 +207,8 @@ fun LongCommunityHeader( ) } - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + LaunchedEffect(key1 = noteEvent) { + val participants = noteEvent?.moderators() if (participants != null) { accountViewModel.loadParticipants(participants) { newParticipantUsers -> @@ -263,12 +263,10 @@ fun ShortCommunityHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + val noteEvent by observeNoteEvent(baseNote) Row(verticalAlignment = Alignment.CenterVertically) { - noteEvent.image()?.let { + noteEvent?.image()?.let { RobohashFallbackAsyncImage( robot = baseNote.idHex, model = it.imageUrl, @@ -290,7 +288,7 @@ fun ShortCommunityHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = remember(noteState) { noteEvent.dTag() }, + text = noteEvent?.name() ?: baseNote.dTag(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index df9712d1d..5eef3873f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,10 +45,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ShowMoreButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -71,17 +70,9 @@ public fun RenderEmojiPack( accountViewModel: AccountViewModel, onClick: ((EmojiUrlTag) -> Unit)? = null, ) { - val noteEvent by - baseNote - .live() - .metadata - .map { it.note.event } - .distinctUntilChanged() - .observeAsState(baseNote.event) + val noteEvent by observeNoteEvent(baseNote) - if (noteEvent == null || noteEvent !is EmojiPackEvent) return - - (noteEvent as? EmojiPackEvent)?.let { + noteEvent?.let { RenderEmojiPack( noteEvent = it, baseNote = baseNote, @@ -188,14 +179,7 @@ private fun EmojiListOptions( accountViewModel, ) { it?.let { usersEmojiList -> - val hasAddedThis by - remember { - usersEmojiList - .live() - .metadata - .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } - .distinctUntilChanged() - }.observeAsState() + val hasAddedThis by observeNoteAndMap(usersEmojiList) { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } CrossfadeIfEnabled(targetState = hasAddedThis, label = "EmojiListOptions", accountViewModel = accountViewModel) { if (it != true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 02c39c05b..af6299375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -22,16 +22,15 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -68,9 +67,7 @@ private fun ObserverAndRenderNIP95( ) { val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - val appContext = LocalContext.current.applicationContext - - val noteState by content.live().metadata.observeAsState() + val noteState by observeNote(content) val content by remember(noteState) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 17387b223..df3e18d9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,6 +42,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -100,13 +100,12 @@ private fun RenderShortRepositoryHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return + val noteEvent by observeNoteEvent(baseNote) Column( modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), ) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + val title = noteEvent?.name() ?: baseNote.dTag() Text( text = stringRes(id = R.string.git_repository, title), style = MaterialTheme.typography.titleLarge, @@ -115,7 +114,7 @@ private fun RenderShortRepositoryHeader( modifier = Modifier.fillMaxWidth(), ) - noteEvent.description()?.let { + noteEvent?.description()?.let { Spacer(modifier = DoubleVertSpacer) Text( text = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index d246ea254..2165dadab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,6 +40,8 @@ import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji @@ -218,7 +219,7 @@ fun DisplayEntryForUser( accountViewModel: AccountViewModel, nav: INav, ) { - val userMetadata by baseUser.live().userMetadataInfo.observeAsState() + val userMetadata by observeUserInfo(baseUser) CreateClickableTextWithEmoji( clickablePart = userMetadata?.bestName() ?: baseUser.pubkeyDisplayHex(), @@ -236,7 +237,7 @@ fun DisplayEntryForNote( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by note.live().metadata.observeAsState() + val noteState by observeNote(note) val author = userBase ?: noteState?.note?.author diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt index de6814f1a..276837be7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -29,7 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +39,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -62,38 +65,38 @@ fun RenderInteractiveStory( val address = baseNote.address() ?: return // keep updating the root event with new versions - val note = baseNote.live().metadata.observeAsState() + val note = observeNote(baseNote) val rootEvent = note.value?.note?.event as? InteractiveStoryBaseEvent ?: return // keep updating the reading state event with new versions val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toValue()) - val latestReadingNoteState = readingStateNote.live().metadata.observeAsState() - val readingState = latestReadingNoteState.value?.note?.event as? InteractiveStoryReadingStateEvent + val readingState by observeNoteEvent(readingStateNote) val currentScene = readingState?.currentScene() if (currentScene != null && currentScene != rootEvent.address()) { LoadAddressableNote(currentScene, accountViewModel) { currentSceneBaseNote -> - val currentScene = currentSceneBaseNote?.live()?.metadata?.observeAsState() - val currentSceneEvent = currentScene?.value?.note?.event as? InteractiveStoryBaseEvent + currentSceneBaseNote?.let { + val currentSceneEvent by observeNoteEvent(it) - if (currentSceneEvent != null) { - RenderInteractiveStory( - section = currentSceneEvent, - onSelect = { - val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) - }, - onRestart = { - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) - }, - makeItShort = makeItShort, - canPreview = canPreview, - quotesLeft = quotesLeft, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + currentSceneEvent?.let { + RenderInteractiveStory( + section = it, + onSelect = { + val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + }, + onRestart = { + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + }, + makeItShort = makeItShort, + canPreview = canPreview, + quotesLeft = quotesLeft, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } else { @@ -163,7 +166,7 @@ fun RenderInteractiveStory( options.forEach { opt -> LoadAddressableNote(opt.address, accountViewModel) { note -> if (note != null) { - val optionState = note.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(note) OutlinedButton( onClick = { onSelect(note) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index a3251bba9..988906078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -51,6 +50,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav @@ -130,7 +130,7 @@ fun RenderLiveActivityEventInner( ) { val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - val eventUpdates by baseNote.live().metadata.observeAsState() + val eventUpdates by observeNote(baseNote) val media = remember(eventUpdates) { noteEvent.streaming() } val cover = remember(eventUpdates) { noteEvent.image() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index cef42b915..7efa82b3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -45,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote @@ -130,7 +130,7 @@ fun RenderTextModificationEvent( noteEvent.editedNote()?.let { LoadNote(baseNoteHex = it.eventId, accountViewModel = accountViewModel) { baseNote -> baseNote?.let { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by observeNote(baseNote) val editStateOriginalNote = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 3fd0f6a86..2ebfe7c93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -48,6 +47,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.countToHumanReadableBytes +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.EmptyNav @@ -223,19 +223,29 @@ fun ShortTorrentHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseNote.live().metadata.observeAsState() - val note = channelState?.note ?: return - val noteEvent = note.event as? TorrentEvent ?: return + val noteEvent by observeNoteEvent(baseNote) + ShortTorrentHeader( + title = noteEvent?.title() ?: TorrentEvent.ALT_DESCRIPTION, + size = noteEvent?.totalSizeBytes()?.let { countToHumanReadableBytes(it) } ?: "--", + modifier.clickable { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, + accountViewModel, + nav, + ) +} + +@Composable +fun ShortTorrentHeader( + title: String, + size: String, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = - modifier.clickable { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } - }, + modifier = modifier, ) { - Icons.Outlined.FileOpen - Icon( imageVector = Icons.Outlined.FileOpen, contentDescription = stringRes(id = R.string.torrent_file), @@ -243,17 +253,14 @@ fun ShortTorrentHeader( ) Text( - text = remember(channelState) { noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION }, + text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), + modifier = Modifier.padding(start = 10.dp).weight(1f), ) Text( - text = remember(channelState) { countToHumanReadableBytes(noteEvent.totalSizeBytes()) }, + text = size, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(end = 5.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 6f8297b2e..17a6a217d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -21,221 +21,20 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ThreadLevelCalculator -import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter -import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter -import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DraftEventsFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter -import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -class NostrChannelFeedViewModel( - val channel: Channel, - val account: Account, -) : FeedViewModel(ChannelFeedFilter(channel, account)) { - class Factory( - val channel: Channel, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChannelFeedViewModel = NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel - } -} - -class NostrChatroomFeedViewModel( - val user: ChatroomKey, - val account: Account, -) : FeedViewModel(ChatroomFeedFilter(user, account)) { - class Factory( - val user: ChatroomKey, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChatRoomFeedViewModel = NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel - } -} - -class NostrThreadFeedViewModel( - account: Account, - noteId: String, -) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) { - class Factory( - val account: Account, - val noteId: String, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrThreadFeedViewModel = NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel - } -} - -class NostrHashtagFeedViewModel( - val hashtag: String, - val account: Account, -) : FeedViewModel(HashtagFeedFilter(hashtag, account)) { - class Factory( - val hashtag: String, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHashtagFeedViewModel = NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel - } -} - -class NostrGeoHashFeedViewModel( - val geohash: String, - val account: Account, -) : FeedViewModel(GeoHashFeedFilter(geohash, account)) { - class Factory( - val geohash: String, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrGeoHashFeedViewModel = NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel - } -} - -class NostrCommunityFeedViewModel( - val note: AddressableNote, - val account: Account, -) : FeedViewModel(CommunityFeedFilter(note, account)) { - class Factory( - val note: AddressableNote, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrCommunityFeedViewModel = NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel - } -} - -@Stable -class NostrBookmarkPublicFeedViewModel( - val account: Account, -) : FeedViewModel(BookmarkPublicFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPublicFeedViewModel = NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel - } -} - -@Stable -class NostrBookmarkPrivateFeedViewModel( - val account: Account, -) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPrivateFeedViewModel = NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel - } -} - -@Stable -class NostrNIP90ContentDiscoveryFeedViewModel( - val account: Account, - dvmkey: String, - requestid: String, -) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmkey, requestid)) { - class Factory( - val account: Account, - val dvmkey: String, - val requestid: String, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrNIP90ContentDiscoveryFeedViewModel = NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as NostrNIP90ContentDiscoveryFeedViewModel - } -} - -@Stable -class NostrDraftEventsFeedViewModel( - val account: Account, -) : FeedViewModel(DraftEventsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDraftEventsFeedViewModel = NostrDraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel - } -} - -abstract class LevelFeedViewModel( - localFilter: FeedFilter, -) : FeedViewModel(localFilter) { - var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) - - val hasDragged = mutableStateOf(false) - - val selectedIDHex = - llState.interactionSource.interactions - .onEach { - if (it is DragInteraction.Start) { - hasDragged.value = true - } - }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null, - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val levelCacheFlow: StateFlow> = - feedState.feedContent - .transformLatest { feed -> - emitAll( - if (feed is FeedState.Loaded) { - feed.feed.map { - val cache = mutableMapOf() - it.list.forEach { - ThreadLevelCalculator.replyLevel(it, cache) - } - cache - } - } else { - MutableStateFlow(mapOf()) - }, - ) - }.flowOn(Dispatchers.Default) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - mapOf(), - ) - - fun levelFlowForItem(note: Note) = - levelCacheFlow - .map { - it[note] ?: 0 - }.distinctUntilChanged() -} - @Stable abstract class FeedViewModel( localFilter: FeedFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index ad50a9b12..daad1689e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -25,15 +25,11 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.SpammerAccountsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate @@ -45,26 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -class NostrHiddenAccountsFeedViewModel( - val account: Account, -) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenAccountsFeedViewModel = NostrHiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel - } -} - -class NostrSpammerAccountsFeedViewModel( - val account: Account, -) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrSpammerAccountsFeedViewModel = NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel - } -} - @Stable open class UserFeedViewModel( val dataSource: FeedFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ebc612847..3859b722f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -23,21 +23,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DiscoverNIP89FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter -import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverChatFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverCommunityFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverLiveFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverMarketplaceFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverNIP89FeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter class AccountFeedContentStates( val accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6da9f36ce..32802a952 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -35,6 +35,7 @@ import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectSuccessfulOperations import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache @@ -80,6 +81,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -94,6 +96,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -136,6 +139,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -147,6 +151,8 @@ class AccountViewModel( Dao { val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) + val newNotesPreProcessor = PrecacheNewNotesProcessor(account, LocalCache) + val proxyPortLogic = ProxyPortFlow( account.settings.torSettings.torType, @@ -799,6 +805,12 @@ class AccountViewModel( } } + fun precomputeNewEvents(notes: Set) { + viewModelScope.launch(Dispatchers.Default) { + newNotesPreProcessor.run(notes) + } + } + fun delete(notes: List) { viewModelScope.launch(Dispatchers.IO) { account.delete(notes) } } @@ -1333,6 +1345,7 @@ class AccountViewModel( ) feedStates.updateFeedsWith(newNotes) upgradeAttestations() + precomputeNewEvents(newNotes) } } } @@ -1410,10 +1423,6 @@ class AccountViewModel( } } - fun dismissPaymentRequest(request: Account.PaymentRequest) { - viewModelScope.launch(Dispatchers.IO) { account.dismissPaymentRequest(request) } - } - fun meltCashu( token: CashuToken, context: Context, @@ -1534,6 +1543,8 @@ class AccountViewModel( fun okHttpClientForTrustedRelays(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForTrustedRelays()) + fun dataSources() = Amethyst.instance.sources + suspend fun deleteDraft(draftTag: String) { account.deleteDraft(draftTag) } @@ -1680,6 +1691,18 @@ class AccountViewModel( fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() + fun allAccountsSync(): List = + runBlocking { + LocalPreferences.allSavedAccounts().mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + } + val draftNoteCache = CachedDraftNotes(this) class CachedDraftNotes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 35b99b6eb..65f8058e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -40,8 +40,10 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils +import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinatorSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.AppNavigation @@ -73,18 +75,29 @@ fun LoggedInPage( ), ) - Log.d("ManageRelayServices", "LoggedInPage $accountViewModel") - accountViewModel.firstRoute = route - ManageSpamFilters(accountViewModel) + // Adds this account to the authentication procedures for relays. + RelayAuthSubscription(accountViewModel) + + // Loads account information from Relays. + AccountFilterAssemblerSubscription(accountViewModel) + + // TODO: Is this needed? + RelaySubscriptionsCoordinatorSubscription() + + // Updates local cache of the anti-spam filter choice of this user. + ObserveAntiSpamFilterSettings(accountViewModel) ManageRelayServices(accountViewModel, sharedPreferencesViewModel) + // Turns Embed Tor on if needed. ManageTorInstance(accountViewModel) + // Listens to Amber ListenToExternalSignerIfNeeded(accountViewModel) + // Register token with the Push Notification Provider. NotificationRegistration(accountViewModel) AppNavigation( @@ -95,11 +108,11 @@ fun LoggedInPage( } @Composable -fun ManageSpamFilters(accountViewModel: AccountViewModel) { +fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) { val isSpamActive by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers .collectAsStateWithLifecycle(true) - LocalCache.antiSpam.active = isSpamActive + Amethyst.instance.cache.antiSpam.active = isSpamActive } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 985cf68a3..8822f0a11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -83,7 +82,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType @@ -194,15 +193,6 @@ fun NewPostScreen( } } - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - DisposableEffect(nav, activity) { // Microsoft's swift key sends Gifs as new actions @@ -615,31 +605,34 @@ fun Notifying( ) mentions.forEachIndexed { idx, user -> - val innerUserState by user.live().metadata.observeAsState() - innerUserState?.user?.let { myUser -> - val tags = myUser.info?.tags - - Button( - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.mediumImportanceLink, - ), - onClick = { onClick(myUser) }, - ) { - CreateTextWithEmoji( - text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" }, - tags = tags, - color = Color.White, - textAlign = TextAlign.Center, - ) - } + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = { onClick(user) }, + ) { + DisplayUserNameWithDeleteMark(user) } } } } } +@Composable +private fun DisplayUserNameWithDeleteMark(user: User) { + val innerUserState by observeUser(user) + innerUserState?.user?.let { myUser -> + CreateTextWithEmoji( + text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" }, + tags = myUser.info?.tags, + color = Color.White, + textAlign = TextAlign.Center, + ) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt new file mode 100644 index 000000000..bd4cb054b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/PrecacheNewNotesProcessor.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CancellationException + +class PrecacheNewNotesProcessor( + val account: Account, + val cache: LocalCache, +) { + fun consume(note: Note) { + val noteEvent = note.event + if (noteEvent != null) { + consumeAlreadyVerified(noteEvent, note) + } + } + + fun consumeAlreadyVerified( + event: Event, + note: Note, + ) { + when (event) { + is OtsEvent -> { + // verifies new OTS upon arrival + Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver) + } + + is PrivateOutboxRelayListEvent -> { + if (event.cachedPrivateTags() == null && event.pubKey == account.signer.pubKey) { + event.privateTags(account.signer) { } + } + } + + is DraftEvent -> { + // Avoid decrypting over and over again if the event already exist. + if (!event.isDeleted() && event.preCachedDraft(account.signer) == null && event.pubKey == account.signer.pubKey) { + event.cachedDraft(account.signer) {} + } + } + + is GiftWrapEvent -> { + if (event.innerEventId == null) { + if (event.recipientPubKey() == account.signer.pubKey) { + event.unwrap(account.signer) { + cache.verifyAndConsume(it, null) + cache.copyRelaysFromTo(note, it) + } + } + } + } + + is SealedRumorEvent -> { + if (event.innerEventId == null) { + event.unseal(account.signer) { + cache.justConsume(it, null) + cache.copyRelaysFromTo(note, it) + } + } + } + + is LnZapEvent -> { + event.zapRequest?.let { req -> + if (req.cachedPrivateZap() == null && req.isPrivateZap()) { + // We can't know which account this was for without going through it. + req.decryptPrivateZap(account.signer) {} + } + } + } + + is LnZapRequestEvent -> { + if (event.cachedPrivateZap() == null && event.isPrivateZap()) { + event.decryptPrivateZap(account.signer) { } + } + } + } + } + + fun run(newNotes: Set) { + try { + newNotes.forEach { + consume(it) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt index 27cc5ce63..7b1562f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt @@ -39,13 +39,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -55,16 +55,16 @@ fun BookmarkListScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val publicFeedViewModel: NostrBookmarkPublicFeedViewModel = + val publicFeedViewModel: BookmarkPublicFeedViewModel = viewModel( key = "NostrBookmarkPublicFeedViewModel", - factory = NostrBookmarkPublicFeedViewModel.Factory(accountViewModel.account), + factory = BookmarkPublicFeedViewModel.Factory(accountViewModel.account), ) - val privateFeedViewModel: NostrBookmarkPrivateFeedViewModel = + val privateFeedViewModel: BookmarkPrivateFeedViewModel = viewModel( key = "NostrBookmarkPrivateFeedViewModel", - factory = NostrBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), + factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) val userState by accountViewModel.account.decryptBookmarks.observeAsState() @@ -74,16 +74,16 @@ fun BookmarkListScreen( privateFeedViewModel.invalidateData() } - RenderBookmarkScreen(privateFeedViewModel, accountViewModel, nav, publicFeedViewModel) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) } @Composable @OptIn(ExperimentalFoundationApi::class) private fun RenderBookmarkScreen( - privateFeedViewModel: NostrBookmarkPrivateFeedViewModel, + publicFeedViewModel: BookmarkPublicFeedViewModel, + privateFeedViewModel: BookmarkPrivateFeedViewModel, accountViewModel: AccountViewModel, nav: INav, - publicFeedViewModel: NostrBookmarkPublicFeedViewModel, ) { val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt index 1be1e5b15..09075e261 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter class BookmarkPrivateFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt new file mode 100644 index 000000000..661a5b78c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class BookmarkPrivateFeedViewModel( + val account: Account, +) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrBookmarkPrivateFeedViewModel = BookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt index 6697e73e1..692b81ff6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter class BookmarkPublicFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt new file mode 100644 index 000000000..484825919 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class BookmarkPublicFeedViewModel( + val account: Account, +) : FeedViewModel(BookmarkPublicFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrBookmarkPublicFeedViewModel = BookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 0b998c7c1..e0a5ae308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon @@ -55,7 +55,7 @@ private fun WatchAndDisplayUser( accountViewModel: AccountViewModel, nav: INav, ) { - val userState by author.live().userMetadataInfo.observeAsState() + val userState by observeUserInfo(author) UserDisplayNameLayout( picture = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt index c189b9d51..d660a2f56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -28,9 +28,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 7b6e3173f..ab29a56ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index cf0f03f5b..653c05033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -25,23 +25,20 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound -import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -58,11 +55,11 @@ fun ChatroomView( accountViewModel: AccountViewModel, nav: INav, ) { - val feedViewModel: NostrChatroomFeedViewModel = + val feedViewModel: ChatroomFeedViewModel = viewModel( key = room.hashCode().toString() + "ChatroomViewModels", factory = - NostrChatroomFeedViewModel.Factory( + ChatroomFeedViewModel.Factory( room, accountViewModel.account, ), @@ -120,40 +117,13 @@ fun ChatroomView( @Composable fun ChatroomViewUI( room: ChatroomKey, - feedViewModel: NostrChatroomFeedViewModel, + feedViewModel: ChatroomFeedViewModel, newPostModel: ChatNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(room, accountViewModel) { - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room) - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - - onDispose { NostrChatroomDataSource.stop() } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index b1aa0119e..b2ad7b869 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt @@ -18,10 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey class ChatroomFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt new file mode 100644 index 000000000..d2a22d997 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedViewModel( + val user: ChatroomKey, + val account: Account, +) : FeedViewModel(ChatroomFeedFilter(user, account)) { + class Factory( + val user: ChatroomKey, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChatRoomFeedViewModel = ChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt new file mode 100644 index 000000000..ff01baed5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relays.EOSEAccount +import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +// This allows multiple screen to be listening to tags, even the same tag +class ChatroomQueryState( + val room: ChatroomKey, + val account: Account, +) + +class ChatroomFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + private val latestEOSEs = EOSEAccount() + + fun createMessagesToMeFilter(key: ChatroomQueryState): TypedFilter? = + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + SincePerRelayFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = key.room.users.toList(), + tags = mapOf("p" to listOf(key.account.userProfile().pubkeyHex)), + since = + latestEOSEs.users[key.account.userProfile()] + ?.followList + ?.get(key.room.hashCode().toString()) + ?.relayList, + ), + ) + + fun createMessagesFromMeFilter(key: ChatroomQueryState): TypedFilter? = + if (key.room != null) { + TypedFilter( + types = setOf(FeedType.PRIVATE_DMS), + filter = + SincePerRelayFilter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(key.account.userProfile().pubkeyHex), + tags = mapOf("p" to key.room.users.map { it }), + since = + latestEOSEs.users[key.account.userProfile()] + ?.followList + ?.get(key.room.hashCode().toString()) + ?.relayList, + ), + ) + } else { + null + } + + fun clearEOSEs(account: Account) { + latestEOSEs.removeDataFor(account.userProfile()) + } + + fun mergeAllFilters(key: ChatroomQueryState): List? = + listOfNotNull( + createMessagesToMeFilter(key), + createMessagesFromMeFilter(key), + ).ifEmpty { null } + + fun newSub(key: ChatroomQueryState): Subscription = + requestNewSubscription { time, relayUrl -> + latestEOSEs.addOrUpdate(key.account.userProfile(), key.room.hashCode().toString(), relayUrl, time) + } + + val userSubscriptionMap = mutableMapOf() + + fun findOrCreateSubFor(key: ChatroomQueryState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + dismissSubscription(it.value) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt new file mode 100644 index 000000000..aabe83508 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +@Composable +fun ChatroomFilterAssemblerSubscription( + room: ChatroomKey, + dataSource: ChatroomFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChatroomQueryState(room, accountViewModel.account) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt index 7b971502d..4d309df3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RoomNameOnlyDisplay.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserShortName import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -173,13 +174,7 @@ fun ShortUsernameDisplay( fontWeight: FontWeight = FontWeight.Bold, accountViewModel: AccountViewModel, ) { - val userName by - baseUser - .live() - .metadata - .map { it.user.toBestShortFirstName() } - .distinctUntilChanged() - .observeAsState(baseUser.toBestShortFirstName()) + val userName by observeUserShortName(baseUser) CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) { CreateTextWithEmoji( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 2d0729965..31cf1db37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -37,7 +37,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor @@ -537,8 +536,6 @@ class ChatNewMessageViewModel : emojiSuggestions?.reset() draftTag.rotate() - - NostrSearchEventOrUserDataSource.clear() } fun deleteDraft() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index e78f36ff9..87331176c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -53,7 +53,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -85,7 +84,6 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -184,15 +182,6 @@ fun NewGroupDMScreen( } } - DisposableEffect(Unit) { - NostrSearchEventOrUserDataSource.start() - - onDispose { - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - WatchAndLoadMyEmojiList(accountViewModel) Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt index 99f1c3b11..85d6f8bc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt @@ -26,10 +26,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt index a5c77336a..2a14d4007 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt @@ -25,24 +25,20 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow @@ -72,11 +68,11 @@ fun PrepareChannelViewModels( accountViewModel: AccountViewModel, nav: INav, ) { - val feedViewModel: NostrChannelFeedViewModel = + val feedViewModel: ChannelFeedViewModel = viewModel( key = baseChannel.idHex + "ChannelFeedViewModel", factory = - NostrChannelFeedViewModel.Factory( + ChannelFeedViewModel.Factory( baseChannel, accountViewModel.account, ), @@ -98,45 +94,13 @@ fun PrepareChannelViewModels( @Composable fun ChannelView( channel: Channel, - feedViewModel: NostrChannelFeedViewModel, + feedViewModel: ChannelFeedViewModel, newPostModel: ChannelNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(accountViewModel) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - - onDispose { - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") - - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel) Column(Modifier.fillMaxHeight()) { Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 09cd20188..871bf8b85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder class ChannelFeedFilter( val channel: Channel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt new file mode 100644 index 000000000..d96587aff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class ChannelFeedViewModel( + val channel: Channel, + val account: Account, +) : FeedViewModel(ChannelFeedFilter(channel, account)) { + class Factory( + val channel: Channel, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChannelFeedViewModel = ChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt new file mode 100644 index 000000000..d37816325 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relays.EOSEAccount +import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent + +// This allows multiple screen to be listening to tags, even the same tag +class ChannelQueryState( + var channel: Channel, + var account: Account, +) + +class ChannelFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + companion object { + val RELAY_SET = + setOf( + FeedType.FOLLOWS, + FeedType.PRIVATE_DMS, + FeedType.GLOBAL, + FeedType.SEARCH, + ) + + val PUBLIC_CHAT_LIST = listOf(ChannelMessageEvent.KIND) + val LIVE_ACTIVITY_LIST = listOf(LiveActivitiesChatMessageEvent.KIND) + } + + private val latestEOSEs = EOSEAccount() + + fun createMessagesByMeToChannelFilter(key: ChannelQueryState): TypedFilter? = + when (key.channel) { + is PublicChatChannel -> + TypedFilter( + types = RELAY_SET, + filter = + SincePerRelayFilter( + kinds = PUBLIC_CHAT_LIST, + authors = listOf(key.account.userProfile().pubkeyHex), + limit = 50, + ), + ) + is LiveActivitiesChannel -> + TypedFilter( + types = RELAY_SET, + filter = + SincePerRelayFilter( + kinds = LIVE_ACTIVITY_LIST, + authors = listOf(key.account.userProfile().pubkeyHex), + limit = 50, + ), + ) + else -> { + null + } + } + + fun createMessagesToChannelFilter(key: ChannelQueryState): TypedFilter? { + return when (key.channel) { + is PublicChatChannel -> + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SincePerRelayFilter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to listOfNotNull(key.channel.idHex)), + limit = 200, + ), + ) + is LiveActivitiesChannel -> + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SincePerRelayFilter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOfNotNull(key.channel.idHex)), + limit = 200, + ), + ) + else -> { + null + } + } + return null + } + + fun mergeAllFilters(key: ChannelQueryState): List? = + listOfNotNull( + createMessagesToChannelFilter(key), + createMessagesByMeToChannelFilter(key), + ).ifEmpty { null } + + fun newSub(key: ChannelQueryState): Subscription = + requestNewSubscription { time, relayUrl -> + latestEOSEs.addOrUpdate(key.account.userProfile(), key.channel.idHex, relayUrl, time) + } + + val userSubscriptionMap = mutableMapOf() + + fun findOrCreateSubFor(key: ChannelQueryState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + dismissSubscription(it.value) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt new file mode 100644 index 000000000..f05f733ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ChannelFilterAssemblerSubscription( + channel: Channel, + dataSource: ChannelFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChannelQueryState(channel, accountViewModel.account) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index eed1fe260..8d804817f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -46,6 +46,7 @@ import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -75,7 +76,7 @@ fun LongPublicChatChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel) val channel = channelState?.channel as? PublicChatChannel ?: return Spacer(StdVertSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index ea1461986..a87cda003 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,6 +39,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav @@ -59,7 +59,7 @@ fun ShortPublicChatChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel) val channel = channelState?.channel as? PublicChatChannel ?: return Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt index 9b19653f7..ae33ff953 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.INav @@ -77,7 +77,7 @@ fun LongLiveActivityChannelHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt index c1767758d..a2e841b96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt @@ -28,13 +28,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -48,7 +48,7 @@ fun ShortLiveActivityChannelHeader( nav: INav, showFlag: Boolean, ) { - val channelState by baseChannel.live.observeAsState() + val channelState by observeChannel(baseChannel) val channel = channelState?.channel as? LiveActivitiesChannel ?: return Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt index e8c3d106d..36254a842 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -48,6 +49,7 @@ fun ShowVideoStreaming( event = it, accountViewModel = accountViewModel, ) { + ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel.dataSources().channelFinder) val streamingInfoEvent by baseChannel.live .map { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 01d933ea7..6bb433c19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -42,7 +42,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -496,8 +495,6 @@ open class ChannelNewMessageViewModel : emojiSuggestions?.reset() draftTag.rotate() - - NostrSearchEventOrUserDataSource.clear() } fun deleteDraft() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 79f6e64a2..f57216b88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import android.R.attr.description import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -35,7 +34,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -59,7 +57,9 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout @@ -94,7 +94,7 @@ fun ChatroomHeaderCompose( if (baseNote.event != null) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { - val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) + val hasEvent by observeNoteHasEvent(baseNote) if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { @@ -171,13 +171,8 @@ private fun ChannelRoomCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val authorState by note.author!! - .live() - .metadata - .observeAsState() - val authorName = remember(note, authorState) { authorState?.user?.toBestDisplayName() } - - val channelState by channel.live.observeAsState() + val authorName by observeUserName(note.author!!) + val channelState by observeChannel(channel) val channelPicture = channelState?.channel?.profilePicture() ?: channel.profilePicture() val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 0d9622e37..6656e4f32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -18,16 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import kotlin.collections.get class ChatroomListKnownFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 1746610ae..62ba9da49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -18,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import kotlin.collections.get class ChatroomListNewFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 37fc97c84..153d1b19d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -18,26 +18,35 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { - lateinit var account: Account +// This allows multiple screen to be listening to tags, even the same tag +class ChatroomListState( + val account: Account, +) +class ChatroomListFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { val latestEOSEs = EOSEAccount() - val chatRoomList = "ChatroomList" + val chatRoomListKey = "ChatroomList" - fun createMessagesToMeFilter() = + fun createMessagesToMeFilter(account: Account) = TypedFilter( types = setOf(FeedType.PRIVATE_DMS), filter = @@ -47,12 +56,12 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(chatRoomList) + ?.get(chatRoomListKey) ?.relayList, ), ) - fun createMessagesFromMeFilter() = + fun createMessagesFromMeFilter(account: Account) = TypedFilter( types = setOf(FeedType.PRIVATE_DMS), filter = @@ -62,27 +71,31 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(chatRoomList) + ?.get(chatRoomListKey) ?.relayList, ), ) - fun createChannelsCreatedbyMeFilter() = + fun createChannelsCreatedbyMeFilter(account: Account) = TypedFilter( types = setOf(FeedType.PUBLIC_CHATS), filter = SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ), authors = listOf(account.userProfile().pubkeyHex), since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(chatRoomList) + ?.get(chatRoomListKey) ?.relayList, ), ) - fun createMyChannelsFilter(): TypedFilter? { + fun createMyChannelsFilter(account: Account): TypedFilter? { val followingEvents = account.selectedChatsFollowList() if (followingEvents.isEmpty()) return null @@ -97,13 +110,13 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(chatRoomList) + ?.get(chatRoomListKey) ?.relayList, ), ) } - fun createLastChannelInfoFilter(): List? { + fun createLastChannelInfoFilter(account: Account): List? { val followingEvents = account.selectedChatsFollowList() if (followingEvents.isEmpty()) return null @@ -122,7 +135,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { } } - fun createLastMessageOfEachChannelFilter(): List? { + fun createLastMessageOfEachChannelFilter(account: Account): List? { val followingEvents = account.selectedChatsFollowList() if (followingEvents.isEmpty()) return null @@ -137,7 +150,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(chatRoomList) + ?.get(chatRoomListKey) ?.relayList, // Remember to consider spam that is being removed from the UI limit = 50, @@ -146,25 +159,51 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { } } - val chatroomListChannel = - requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) + fun mergeAllFilters(account: Account): List? = + listOfNotNull( + listOfNotNull( + createMessagesToMeFilter(account), + createMessagesFromMeFilter(account), + createMyChannelsFilter(account), + ), + createLastChannelInfoFilter(account), + createLastMessageOfEachChannelFilter(account), + ).flatten().ifEmpty { null } + + fun newSub(key: ChatroomListState): Subscription = + requestNewSubscription { time, relayUrl -> + latestEOSEs.addOrUpdate(key.account.userProfile(), chatRoomListKey, relayUrl, time) } - override fun updateChannelFilters() { - val list = - listOfNotNull( - createMessagesToMeFilter(), - createMessagesFromMeFilter(), - createMyChannelsFilter(), - ) + val userSubscriptionMap = mutableMapOf() - chatroomListChannel.typedFilters = - listOfNotNull( - list, - createLastChannelInfoFilter(), - createLastMessageOfEachChannelFilter(), - ).flatten() - .ifEmpty { null } + fun findOrCreateSubFor(key: ChatroomListState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it.account) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + dismissSubscription(it.value) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index c3f79437d..fbd5598da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/WatchAccountForListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -18,28 +18,24 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable -fun WatchAccountForListScreen( - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, +fun ChatroomListFilterAssemblerSubscription( + dataSource: ChatroomListFilterAssembler, accountViewModel: AccountViewModel, ) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { - NostrChatroomListDataSource.account = accountViewModel.account - NostrChatroomListDataSource.start() - knownFeedContentState.invalidateData(true) - newFeedContentState.invalidateData(true) + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + ChatroomListState(accountViewModel.account) } - } + + KeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 4384893bd..19765d649 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -29,15 +29,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.MainTopBar import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchAccountForListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem @@ -54,8 +54,10 @@ fun MessagesSinglePane( val markKnownAsRead = remember { mutableStateOf(false) } val markNewAsRead = remember { mutableStateOf(false) } - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) + WatchLifecycleAndUpdateModel(knownFeedContentState) + WatchLifecycleAndUpdateModel(newFeedContentState) + + ChatroomListFilterAssemblerSubscription(accountViewModel.dataSources().chatroomList, accountViewModel) val tabs by remember(knownFeedContentState, markKnownAsRead) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index 5bd153c53..cee6c8bcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -31,10 +31,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchAccountForListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.WatchLifecycleAndRefreshDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem @@ -51,8 +51,10 @@ fun ChatroomList( val markKnownAsRead = remember { mutableStateOf(false) } val markNewAsRead = remember { mutableStateOf(false) } - WatchAccountForListScreen(knownFeedContentState, newFeedContentState, accountViewModel) - WatchLifecycleAndRefreshDataSource(accountViewModel) + WatchLifecycleAndUpdateModel(knownFeedContentState) + WatchLifecycleAndUpdateModel(newFeedContentState) + + ChatroomListFilterAssemblerSubscription(accountViewModel.dataSources().chatroomList, accountViewModel) val tabs by remember(knownFeedContentState, markKnownAsRead) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 7c126bd16..f6e6f92a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -36,17 +36,16 @@ import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.vitorpamplona.amethyst.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.MainTopBar import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp -import kotlinx.coroutines.channels.Channel @Composable fun MessagesTwoPane( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index f0d51ba40..fe0161398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -27,24 +27,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.NostrCommunityDataSource +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader -import com.vitorpamplona.amethyst.ui.screen.NostrCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight @Composable @@ -72,11 +68,11 @@ fun PrepareViewModelsCommunityScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrCommunityFeedViewModel = + val followsFeedViewModel: CommunityFeedViewModel = viewModel( key = note.idHex + "CommunityFeedViewModel", factory = - NostrCommunityFeedViewModel.Factory( + CommunityFeedViewModel.Factory( note, accountViewModel.account, ), @@ -88,34 +84,12 @@ fun PrepareViewModelsCommunityScreen( @Composable fun CommunityScreen( note: AddressableNote, - feedViewModel: NostrCommunityFeedViewModel, + feedViewModel: CommunityFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrCommunityDataSource.loadCommunity(note) - - LaunchedEffect(note) { feedViewModel.invalidateData() } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Community Start") - NostrCommunityDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Community Stop") - NostrCommunityDataSource.loadCommunity(null) - NostrCommunityDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community) DisappearingScaffold( isInvertedLayout = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt index f8108d057..3a069a740 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt new file mode 100644 index 000000000..37ce7c587 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +class CommunityFeedViewModel( + val note: AddressableNote, + val account: Account, +) : FeedViewModel(CommunityFeedFilter(note, account)) { + class Factory( + val note: AddressableNote, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrCommunityFeedViewModel = CommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt new file mode 100644 index 000000000..9efd062cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import kotlin.collections.ifEmpty + +// This allows multiple screen to be listening to tags, even the same tag +class CommunityQueryState( + var community: AddressableNote, +) + +class CommunityFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + private fun createLoadCommunityFilter(keys: Set): List { + if (keys.isEmpty()) return emptyList() + + val uniqueCommunities = + keys.associate { + it.community.address.toValue() to it.community + } + + return uniqueCommunities.mapNotNull { + val commEvent = it.value.event + if (commEvent is CommunityDefinitionEvent) { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + authors = commEvent.moderators().map { it.pubKey }.plus(listOfNotNull(it.value.author?.pubkeyHex)), + tags = mapOf("a" to listOf(it.value.address.toValue())), + kinds = listOf(CommunityPostApprovalEvent.KIND), + limit = 500, + ), + ) + } else { + null + } + } + } + + val loadCommunityChannel = requestNewSubscription() + + override fun updateSubscriptions(keys: Set) { + loadCommunityChannel.typedFilters = createLoadCommunityFilter(keys).ifEmpty { null } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt new file mode 100644 index 000000000..bbc7f0555 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun CommunityFilterAssemblerSubscription( + channel: AddressableNote, + filterAssembler: CommunityFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(channel) { + CommunityQueryState(channel) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 1d876a1b4..d37443732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,11 +49,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -66,13 +62,15 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -177,22 +175,13 @@ fun DiscoverScreen( accountViewModel = accountViewModel, ) - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Discovery Start") - NostrDiscoveryDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Discovery Stop") - NostrDiscoveryDataSource.stop() - } - } + WatchLifecycleAndUpdateModel(discoveryContentNIP89FeedContentState) + WatchLifecycleAndUpdateModel(discoveryMarketplaceFeedContentState) + WatchLifecycleAndUpdateModel(discoveryLiveFeedContentState) + WatchLifecycleAndUpdateModel(discoveryCommunityFeedContentState) + WatchLifecycleAndUpdateModel(discoveryChatFeedContentState) - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel) DiscoverPages(pagerState, tabs, accountViewModel, nav) } @@ -363,7 +352,6 @@ fun WatchAccountForDiscoveryScreen( val listState by accountViewModel.account.liveDiscoveryFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState) { - NostrDiscoveryDataSource.resetFilters() discoveryContentNIP89FeedContentState.checkKeysInvalidateDataAndSendToTop() discoveryMarketplaceFeedContentState.checkKeysInvalidateDataAndSendToTop() discoveryLiveFeedContentState.checkKeysInvalidateDataAndSendToTop() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverChatFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverChatFeedFilter.kt index f9d25420b..3b38674e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverChatFeedFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip51Lists.MuteListEvent @@ -65,7 +67,7 @@ open class DiscoverChatFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverCommunityFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverCommunityFeedFilter.kt index 6737d99be..2678b4b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverCommunityFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent @@ -42,7 +44,7 @@ open class DiscoverCommunityFeedFilter( override fun feed(): List { val filterParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, @@ -71,7 +73,7 @@ open class DiscoverCommunityFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { // here, we need to look for CommunityDefinition in new collection AND new CommunityDefinition from Post Approvals val filterParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverLiveFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverLiveFeedFilter.kt index 85c56cba2..c18b60f13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverLiveFeedFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -53,7 +55,7 @@ open class DiscoverLiveFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { val filterParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverMarketplaceFeedFilter.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverMarketplaceFeedFilter.kt index 581f03a56..718d11e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverMarketplaceFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -53,7 +55,7 @@ open class DiscoverMarketplaceFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( account.userProfile().pubkeyHex, account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverNIP89FeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverNIP89FeedFilter.kt index 6fc4cf22c..9413e683e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/dal/DiscoverNIP89FeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -54,7 +56,7 @@ open class DiscoverNIP89FeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( account.userProfile().pubkeyHex, account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt new file mode 100644 index 000000000..23ca40de0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -0,0 +1,509 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.amethyst.service.relays.EOSEAccount +import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.collections.flatten + +// This allows multiple screen to be listening to tags, even the same tag +class DiscoveryQueryState( + val account: Account, + val scope: CoroutineScope, +) + +class DiscoveryFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + val latestEOSEs = EOSEAccount() + + fun since(key: DiscoveryQueryState) = + latestEOSEs.users[key.account.userProfile()] + ?.followList + ?.get(key.account.settings.defaultDiscoveryFollowList.value) + ?.relayList + + fun newEose( + key: DiscoveryQueryState, + relayUrl: String, + time: Long, + ) = latestEOSEs.addOrUpdate( + key.account.userProfile(), + key.account.settings.defaultDiscoveryFollowList.value, + relayUrl, + time, + ) + + fun createMarketplaceFilter(key: DiscoveryQueryState): List { + val follows = + key.account.liveDiscoveryListAuthorsPerRelay.value + ?.ifEmpty { null } + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.hashtags + ?.toList() + ?.ifEmpty { null } + val geohashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.geotags + ?.toList() + ?.ifEmpty { null } + + return listOfNotNull( + TypedFilter( + types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since(key), + ), + ), + hashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "t" to + it + .map { + listOf( + it, + it.lowercase(), + it.uppercase(), + it.capitalize(), + ) + }.flatten(), + ), + limit = 300, + since = since(key), + ), + ) + }, + geohashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(ClassifiedsEvent.KIND), + tags = + mapOf( + "g" to it, + ), + limit = 300, + since = since(key), + ), + ) + }, + ) + } + + fun createNIP89Filter(key: DiscoveryQueryState): List = + listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + tags = mapOf("k" to listOf("5300")), + since = since(key), + ), + ), + ) + + fun createLiveStreamFilter(key: DiscoveryQueryState): List { + val follows = + key.account.liveDiscoveryFollowLists.value + ?.authors + ?.toList() + ?.ifEmpty { null } + + val followsRelays = key.account.liveDiscoveryListAuthorsPerRelay.value + + return listOfNotNull( + TypedFilter( + types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + authors = followsRelays, + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + ), + limit = 300, + since = since(key), + ), + ), + follows?.let { + TypedFilter( + types = setOf(FeedType.FOLLOWS), + filter = + SincePerRelayFilter( + tags = mapOf("p" to it), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = since(key), + ), + ) + }, + ) + } + + fun createPublicChatFilter(key: DiscoveryQueryState): List { + val follows = + key.account.liveDiscoveryListAuthorsPerRelay.value + ?.ifEmpty { null } + val followChats = key.account.selectedChatsFollowList().toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = listOf(ChannelMessageEvent.KIND), + limit = 500, + since = since(key), + ), + ), + if (followChats.isNotEmpty()) { + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SincePerRelayFilter( + ids = followChats, + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since(key), + ), + ) + } else { + null + }, + ) + } + + fun createCommunitiesFilter(key: DiscoveryQueryState): TypedFilter { + val follows = key.account.liveDiscoveryListAuthorsPerRelay.value + + return TypedFilter( + types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), + filter = + SinceAuthorPerRelayFilter( + authors = follows, + kinds = + listOf( + CommunityDefinitionEvent.KIND, + CommunityPostApprovalEvent.KIND, + ), + limit = 300, + since = since(key), + ), + ) + } + + fun createLiveStreamTagsFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.hashtags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + ), + tags = + mapOf( + "t" to + hashToLoad + .map { + listOf( + it, + it.lowercase(), + it.uppercase(), + it.capitalize(), + ) + }.flatten(), + ), + limit = 300, + since = since(key), + ), + ) + } + + fun createLiveStreamGeohashesFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.geotags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + ), + tags = mapOf("g" to hashToLoad), + limit = 300, + since = since(key), + ), + ) + } + + fun createPublicChatsTagsFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.hashtags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SincePerRelayFilter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + tags = + mapOf( + "t" to + hashToLoad + .map { + listOf( + it, + it.lowercase(), + it.uppercase(), + it.capitalize(), + ) + }.flatten(), + ), + limit = 300, + since = since(key), + ), + ) + } + + fun createPublicChatsGeohashesFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.geotags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = + SincePerRelayFilter( + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + tags = + mapOf("g" to hashToLoad), + limit = 300, + since = since(key), + ), + ) + } + + fun createCommunitiesTagsFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.hashtags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = + listOf( + CommunityDefinitionEvent.KIND, + CommunityPostApprovalEvent.KIND, + ), + tags = + mapOf( + "t" to + hashToLoad + .map { + listOf( + it, + it.lowercase(), + it.uppercase(), + it.capitalize(), + ) + }.flatten(), + ), + limit = 300, + since = since(key), + ), + ) + } + + fun createCommunitiesGeohashesFilter(key: DiscoveryQueryState): TypedFilter? { + val hashToLoad = + key.account.liveDiscoveryFollowLists.value + ?.geotags + ?.toList() + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = + SincePerRelayFilter( + kinds = + listOf( + CommunityDefinitionEvent.KIND, + CommunityPostApprovalEvent.KIND, + ), + tags = mapOf("g" to hashToLoad), + limit = 300, + since = since(key), + ), + ) + } + + fun mergeAllFilters(key: DiscoveryQueryState): List? = + createLiveStreamFilter(key) + .plus(createNIP89Filter(key)) + .plus(createPublicChatFilter(key)) + .plus(createMarketplaceFilter(key)) + .plus( + listOfNotNull( + createLiveStreamTagsFilter(key), + createLiveStreamGeohashesFilter(key), + createCommunitiesFilter(key), + createCommunitiesTagsFilter(key), + createCommunitiesGeohashesFilter(key), + createPublicChatsTagsFilter(key), + createPublicChatsGeohashesFilter(key), + ), + ).toList() + .ifEmpty { null } + + val userJobMap = mutableMapOf() + val userSubscriptionMap = mutableMapOf() + + fun newSub(key: DiscoveryQueryState): Subscription { + userJobMap[key.account.userProfile()]?.cancel() + userJobMap[key.account.userProfile()] = + key.scope.launch(Dispatchers.Default) { + key.account.liveDiscoveryFollowLists.collect { + invalidateFilters() + } + } + + return requestNewSubscription { time, relayUrl -> + newEose(key, relayUrl, time) + } + } + + fun endSub( + key: User, + subId: String, + ) { + dismissSubscription(subId) + userJobMap[key]?.cancel() + } + + fun findOrCreateSubFor(key: DiscoveryQueryState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + endSub(it.key, it.value) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt new file mode 100644 index 000000000..29cf4be8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun DiscoveryFilterAssemblerSubscription( + dataSource: DiscoveryFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + DiscoveryQueryState(accountViewModel.account, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 17707e6f9..e2fb1f139 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -56,14 +56,14 @@ import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.screen.NostrDraftEventsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -74,10 +74,10 @@ fun DraftListScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val draftFeedViewModel: NostrDraftEventsFeedViewModel = + val draftFeedViewModel: DraftEventsFeedViewModel = viewModel( key = "NostrDraftEventsFeedViewModel", - factory = NostrDraftEventsFeedViewModel.Factory(accountViewModel.account), + factory = DraftEventsFeedViewModel.Factory(accountViewModel.account), ) RenderDraftListScreen(draftFeedViewModel, accountViewModel, nav) @@ -85,7 +85,7 @@ fun DraftListScreen( @Composable private fun RenderDraftListScreen( - feedViewModel: NostrDraftEventsFeedViewModel, + feedViewModel: DraftEventsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt index 0b42f7c79..2b1c217c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent class DraftEventsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt new file mode 100644 index 000000000..17eca1586 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/dal/DraftEventsFeedViewModel.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class DraftEventsFeedViewModel( + val account: Account, +) : FeedViewModel(DraftEventsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDraftEventsFeedViewModel = DraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b6e7321b3..9d8cfe558 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,18 +55,20 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.DVMCard import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture @@ -79,11 +80,10 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.elements.customZapClick import com.vitorpamplona.amethyst.ui.note.payViaIntent -import com.vitorpamplona.amethyst.ui.screen.NostrNIP90ContentDiscoveryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp @@ -194,7 +194,8 @@ fun ObserverContentDiscoveryResponse( nav: INav, ) { val noteAuthor = appDefinition.author ?: return - val updateFiltersFromRelays = dvmRequestId.live().metadata.observeAsState() + + EventFinderFilterAssemblerSubscription(dvmRequestId) val resultFlow = remember(dvmRequestId) { @@ -261,10 +262,10 @@ fun PrepareViewContentDiscoveryModels( accountViewModel: AccountViewModel, nav: INav, ) { - val resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel = + val resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel = viewModel( key = "NostrNIP90ContentDiscoveryFeedViewModel${dvm.pubkeyHex}$dvmRequestId", - factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = dvm.pubkeyHex, requestid = dvmRequestId), + factory = NIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmKey = dvm.pubkeyHex, requestId = dvmRequestId), ) LaunchedEffect(key1 = dvmRequestId, latestResponse.id) { @@ -276,7 +277,7 @@ fun PrepareViewContentDiscoveryModels( @Composable fun RenderNostrNIP90ContentDiscoveryScreen( - resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel, + resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel, onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: INav, @@ -447,8 +448,8 @@ fun ZapDVMButton( ) } - // Makes sure the user is loaded to get his ln address - val userState = noteAuthor.live().metadata.observeAsState() + // Makes sure the user is loaded to get his ln address ahead of time. + UserFinderFilterAssemblerSubscription(noteAuthor) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -640,25 +641,8 @@ fun convertAppMetadataToCard(metadata: AppMetadata?): DVMCard { @Composable fun observeAppDefinition(appDefinitionNote: Note): DVMCard { - val noteEvent = - appDefinitionNote.event as? AppDefinitionEvent ?: return DVMCard( - name = "", - description = "", - cover = null, - amount = "", - personalized = false, - ) - - val card by - appDefinitionNote - .live() - .metadata - .map { - convertAppMetadataToCard((it.note.event as? AppDefinitionEvent)?.appMetaData()) - }.distinctUntilChanged() - .observeAsState( - convertAppMetadataToCard(noteEvent.appMetaData()), - ) - + val card by observeNoteAndMap(appDefinitionNote) { + convertAppMetadataToCard((it.event as? AppDefinitionEvent)?.appMetaData()) + } return card } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt new file mode 100644 index 000000000..62bfe7d1f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class NIP90ContentDiscoveryFeedViewModel( + val account: Account, + dvmKey: String, + requestId: String, +) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmKey, requestId)) { + class Factory( + val account: Account, + val dvmKey: String, + val requestId: String, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrNIP90ContentDiscoveryFeedViewModel = NIP90ContentDiscoveryFeedViewModel(account, dvmKey, requestId) as NostrNIP90ContentDiscoveryFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index e489bc323..a652aeda5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent @@ -41,8 +43,8 @@ open class NIP90ContentDiscoveryResponseFilter( open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) fun acceptableEvent(note: Note): Boolean { val noteEvent = note.event @@ -70,7 +72,7 @@ open class NIP90ContentDiscoveryResponseFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( account.userProfile().pubkeyHex, account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 2bcd6fa01..85cd3de8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -20,40 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrGeohashDataSource +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName -import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal.GeoHashFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun GeoHashScreen( @@ -72,65 +61,31 @@ fun PrepareViewModelsGeoHashScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrGeoHashFeedViewModel = + val geohashViewModel: GeoHashFeedViewModel = viewModel( key = tag + "GeoHashFeedViewModel", - factory = - NostrGeoHashFeedViewModel.Factory( - tag, - accountViewModel.account, - ), + factory = GeoHashFeedViewModel.Factory(tag, accountViewModel.account), ) - GeoHashScreen(tag, followsFeedViewModel, accountViewModel, nav) + GeoHashScreen(tag, geohashViewModel, accountViewModel, nav) } @Composable fun GeoHashScreen( tag: String, - feedViewModel: NostrGeoHashFeedViewModel, + feedViewModel: GeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrGeohashDataSource.loadHashtag(tag) - - DisposableEffect(tag) { - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - onDispose { - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrGeohashDataSource.loadHashtag(tag) - NostrGeohashDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrGeohashDataSource.loadHashtag(null) - NostrGeohashDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + GeoHashFilterAssemblerSubscription(tag, accountViewModel.dataSources().geohashes) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - DislayGeoTagHeader(tag, Modifier.weight(1f)) + DisplayGeoTagHeader(tag, Modifier.weight(1f)) GeoHashActionOptions(tag, accountViewModel) }, popBack = nav::popBack, @@ -150,34 +105,7 @@ fun GeoHashScreen( } @Composable -fun GeoHashHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, - onClick: () -> Unit = {}, -) { - Column( - Modifier.fillMaxWidth().clickable { onClick() }, - ) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - DislayGeoTagHeader(tag, remember { Modifier.weight(1f) }) - - GeoHashActionOptions(tag, account) - } - } - - HorizontalDivider( - thickness = DividerThickness, - ) - } -} - -@Composable -fun DislayGeoTagHeader( +fun DisplayGeoTagHeader( geohash: String, modifier: Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 6061f6b34..b2a0bdffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash @@ -35,12 +37,13 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent class GeoHashFeedFilter( val tag: String, val account: Account, + val cache: LocalCache, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List { val notes = - LocalCache.notes.filterIntoSet { _, it -> + cache.notes.filterIntoSet { _, it -> acceptableEvent(it, tag) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt new file mode 100644 index 000000000..94271d800 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedViewModel.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class GeoHashFeedViewModel( + val geohash: String, + val account: Account, +) : FeedViewModel( + GeoHashFeedFilter(geohash, account, LocalCache), + ) { + @Suppress("UNCHECKED_CAST") + class Factory( + val geohash: String, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrGeoHashFeedViewModel = GeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt index dc8f1b73d..e75a4bf9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrGeohashDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -18,9 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -34,50 +37,57 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") { - private var geohashToWatch: String? = null +// This allows multiple screen to be listening to tags, even the same tag +class GeohashQueryState( + val geohash: String, +) - fun createLoadHashtagFilter(): TypedFilter? { - val hashToLoad = geohashToWatch ?: return null +/** + * Creates a filter for multiple geohashes at the same time. + */ +@Stable +class GeoHashFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + companion object { + val GEO_COMPATIBLE_KINDS = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + } + + fun createLoadHashtagFilter(keys: Set): TypedFilter? { + if (keys.isEmpty()) return null + + val unique = + keys + .mapTo(mutableSetOf()) { + it.geohash + }.toList() return TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( - tags = - mapOf( - "g" to - listOf( - hashToLoad, - ), - ), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), + tags = mapOf("g" to unique), + kinds = GEO_COMPATIBLE_KINDS, limit = 200, ), ) } - val loadGeohashChannel = requestNewChannel() + val loadGeohashChannel = requestNewSubscription() - override fun updateChannelFilters() { - loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null } - } - - fun loadHashtag(tag: String?) { - geohashToWatch = tag - - invalidateFilters() + override fun updateSubscriptions(keys: Set) { + loadGeohashChannel.typedFilters = createLoadHashtagFilter(keys)?.let { listOf(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt new file mode 100644 index 000000000..4cffc5c65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun GeoHashFilterAssemblerSubscription( + tag: String, + dataSource: GeoHashFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(tag) { + GeohashQueryState(tag) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index 7d60fb079..28506b8f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -28,26 +28,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrHashtagDataSource +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -69,59 +66,28 @@ fun PrepareViewModelsHashtagScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrHashtagFeedViewModel = + val hashtagFeedViewModel: HashtagFeedViewModel = viewModel( key = tag + "HashtagFeedViewModel", factory = - NostrHashtagFeedViewModel.Factory( + HashtagFeedViewModel.Factory( tag, accountViewModel.account, ), ) - HashtagScreen(tag, followsFeedViewModel, accountViewModel, nav) + HashtagScreen(tag, hashtagFeedViewModel, accountViewModel, nav) } @Composable fun HashtagScreen( tag: String, - feedViewModel: NostrHashtagFeedViewModel, + feedViewModel: HashtagFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - NostrHashtagDataSource.loadHashtag(tag) - - DisposableEffect(tag) { - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - - onDispose { - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hashtag Start") - NostrHashtagDataSource.loadHashtag(tag) - NostrHashtagDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Hashtag Stop") - NostrHashtagDataSource.loadHashtag(null) - NostrHashtagDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + HashtagFilterAssemblerSubscription(tag, accountViewModel.dataSources().hashtags) DisappearingScaffold( isInvertedLayout = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 1881067b1..9036627da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash @@ -37,12 +39,13 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent class HashtagFeedFilter( val tag: String, val account: Account, + val cache: LocalCache, ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List { val notes = - LocalCache.notes.filterIntoSet { _, it -> + cache.notes.filterIntoSet { _, it -> acceptableEvent(it, tag) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt new file mode 100644 index 000000000..be1908aff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedViewModel.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel + +@Stable +class HashtagFeedViewModel( + val hashtag: String, + val account: Account, +) : FeedViewModel( + HashtagFeedFilter(hashtag, account, LocalCache), + ) { + @Suppress("UNCHECKED_CAST") + class Factory( + val hashtag: String, + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHashtagFeedViewModel = HashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 6e238a457..dcbc827bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -18,9 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -35,20 +37,52 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlin.collections.flatten -object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { - private var hashtagToWatch: String? = null +// This allows multiple screen to be listening to tags, even the same tag +class HashtagQueryState( + val hashtag: String, +) - fun createLoadHashtagFilter(): List { - val hashToLoad = hashtagToWatch ?: return emptyList() +class HashtagFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + companion object { + // keeps a maximum of 10 kinds per filter. + val TOPIC_COMPATIBLE_KINDS = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + + val TOPIC_COMPATIBLE_KINDS2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ) + } + + fun createLoadHashtagFilter(keys: Set): List { + if (keys.isEmpty()) return emptyList() val hashtagsToFollow = - listOf( - hashToLoad, - hashToLoad.lowercase(), - hashToLoad.uppercase(), - hashToLoad.capitalize(), - ) + keys + .mapTo(mutableSetOf()) { + setOf( + it.hashtag, + it.hashtag.lowercase(), + it.hashtag.uppercase(), + it.hashtag.capitalize(), + ) + }.flatten() return listOf( TypedFilter( @@ -56,20 +90,7 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { filter = SincePerRelayFilter( tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), + kinds = TOPIC_COMPATIBLE_KINDS, limit = 200, ), ), @@ -78,25 +99,16 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") { filter = SincePerRelayFilter( tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - InteractiveStorySceneEvent.KIND, - ), + kinds = TOPIC_COMPATIBLE_KINDS2, limit = 200, ), ), ) } - val loadHashtagChannel = requestNewChannel() + val loadHashtagChannel = requestNewSubscription() - override fun updateChannelFilters() { - loadHashtagChannel.typedFilters = createLoadHashtagFilter().ifEmpty { null } - } - - fun loadHashtag(tag: String?) { - hashtagToWatch = tag - - invalidateFilters() + override fun updateSubscriptions(keys: Set) { + loadHashtagChannel.typedFilters = createLoadHashtagFilter(keys).ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt new file mode 100644 index 000000000..4d90b73b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun HashtagFilterAssemblerSubscription( + tag: String, + dataSource: HashtagFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(tag) { + HashtagQueryState(tag) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 65f2b4446..8558bd122 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,14 +43,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedContentState @@ -60,12 +55,14 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.TabRowHeight @@ -97,7 +94,9 @@ fun HomeScreen( ) { WatchAccountForHomeScreen(newThreadsFeedState, repliesFeedState, accountViewModel) - WatchLifeCycleChanges(accountViewModel) + WatchLifecycleAndUpdateModel(newThreadsFeedState) + WatchLifecycleAndUpdateModel(repliesFeedState) + HomeFilterAssemblerSubscription(accountViewModel.dataSources().home, accountViewModel) AssembleHomeTabs(newThreadsFeedState, repliesFeedState) { pagerState, tabItems -> HomePages(pagerState, tabItems, accountViewModel, nav) @@ -136,23 +135,6 @@ private fun AssembleHomeTabs( inner(pagerState, tabs) } -@Composable -private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) { - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } -} - @Composable private fun HomePages( pagerState: PagerState, @@ -323,8 +305,6 @@ fun WatchAccountForHomeScreen( val homeFollowList by accountViewModel.account.liveHomeFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, homeFollowList) { - NostrHomeDataSource.account = accountViewModel.account - NostrHomeDataSource.invalidateFilters() newThreadsFeedState.checkKeysInvalidateDataAndSendToTop() repliesFeedState.checkKeysInvalidateDataAndSendToTop() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index e057bc091..7ecc0040e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -18,11 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -54,7 +57,7 @@ class HomeConversationsFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index f1c079d0b..39b5221ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -18,11 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -48,7 +51,7 @@ class HomeNewThreadFeedFilter( account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index a6c0d4066..821504ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -18,13 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -45,51 +48,25 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlin.collections.flatten -object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { - lateinit var account: Account +// This allows multiple screen to be listening to tags, even the same tag +class HomeQueryState( + val account: Account, + val scope: CoroutineScope, +) - val scope = Amethyst.instance.applicationIOScope +class HomeFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { val latestEOSEs = EOSEAccount() - var job: Job? = null - var job2: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveHomeFollowLists.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - - job2?.cancel() - job2 = - scope.launch(Dispatchers.IO) { - account.liveHomeListAuthorsPerRelay.collect { - if (this@NostrHomeDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - job2?.cancel() - } - - fun createFollowAccountsFilter(): List { - val follows = - account.liveHomeListAuthorsPerRelay.value + fun createFollowAccountsFilter(key: HomeQueryState): List { + val follows = key.account.liveHomeListAuthorsPerRelay.value return listOf( TypedFilter( @@ -112,9 +89,9 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { authors = follows, limit = 400, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ), @@ -132,17 +109,17 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { authors = follows, limit = 400, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ), ) } - fun createFollowMetadataAndReleaseFilter(): TypedFilter? { - val follows = account.liveHomeListAuthorsPerRelay.value + fun createFollowMetadataAndReleaseFilter(key: HomeQueryState): TypedFilter? { + val follows = key.account.liveHomeListAuthorsPerRelay.value return if (!follows.isNullOrEmpty()) { TypedFilter( @@ -156,9 +133,9 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { ), authors = follows, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -167,8 +144,10 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { } } - fun createFollowTagsFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null + fun createFollowTagsFilter(key: HomeQueryState): TypedFilter? { + val hashToLoad = + key.account.liveHomeFollowLists.value + ?.hashtags ?: return null if (hashToLoad.isEmpty()) return null @@ -193,21 +172,29 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { mapOf( "t" to hashToLoad - .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } - .flatten(), + .map { + listOf( + it, + it.lowercase(), + it.uppercase(), + it.capitalize(), + ) + }.flatten(), ), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ) } - fun createFollowGeohashesFilter(): TypedFilter? { - val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null + fun createFollowGeohashesFilter(key: HomeQueryState): TypedFilter? { + val hashToLoad = + key.account.liveHomeFollowLists.value + ?.geotags ?: return null if (hashToLoad.isEmpty()) return null @@ -233,16 +220,18 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { ), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ) } - fun createFollowCommunitiesFilter(): TypedFilter? { - val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null + fun createFollowCommunitiesFilter(key: HomeQueryState): TypedFilter? { + val communitiesToLoad = + key.account.liveHomeFollowLists.value + ?.addresses ?: return null if (communitiesToLoad.isEmpty()) return null @@ -267,34 +256,92 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { ), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultHomeFollowList.value) + ?.get(key.account.settings.defaultHomeFollowList.value) ?.relayList, ), ) } - val followAccountChannel = - requestNewChannel { time, relayUrl -> + fun mergeAllFilters(key: HomeQueryState): List? = + ( + createFollowAccountsFilter(key) + + listOfNotNull( + createFollowMetadataAndReleaseFilter(key), + createFollowCommunitiesFilter(key), + createFollowTagsFilter(key), + createFollowGeohashesFilter(key), + ) + ).ifEmpty { null } + + val userJobMap = mutableMapOf() + val userJobMap2 = mutableMapOf() + val userSubscriptionMap = mutableMapOf() + + fun newSub(key: HomeQueryState): Subscription { + userJobMap[key.account.userProfile()]?.cancel() + userJobMap[key.account.userProfile()] = + key.scope.launch(Dispatchers.Default) { + key.account.liveHomeFollowLists.collect { + invalidateFilters() + } + } + + userJobMap2[key.account.userProfile()]?.cancel() + userJobMap2[key.account.userProfile()] = + key.scope.launch(Dispatchers.Default) { + key.account.liveHomeListAuthorsPerRelay.collect { + invalidateFilters() + } + } + + return requestNewSubscription { time, relayUrl -> latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultHomeFollowList.value, + key.account.userProfile(), + key.account.settings.defaultHomeFollowList.value, relayUrl, time, ) } + } - override fun updateChannelFilters() { - followAccountChannel.typedFilters = - ( - createFollowAccountsFilter() + - listOfNotNull( - createFollowMetadataAndReleaseFilter(), - createFollowCommunitiesFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), - ) - ).ifEmpty { null } + fun endSub( + key: User, + subId: String, + ) { + dismissSubscription(subId) + userJobMap[key]?.cancel() + userJobMap2[key]?.cancel() + } + + fun findOrCreateSubFor(key: HomeQueryState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + endSub(it.key, it.value) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt new file mode 100644 index 000000000..1ddbcc4c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun HomeFilterAssemblerSubscription( + dataSource: HomeFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + HomeQueryState(accountViewModel.account, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt index e92b95028..1f07f8192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup import android.app.Activity import android.content.Context @@ -94,6 +94,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.authenticate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 7bb7a6da6..c4c0e45cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index a27d83588..e845a747a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -26,27 +26,22 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold @Composable fun NotificationScreen( @@ -75,20 +70,6 @@ fun NotificationScreen( WatchAccountForNotifications(notifFeedContentState, accountViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -157,8 +138,6 @@ fun WatchAccountForNotifications( accountViewModel.account.liveNotificationFollowLists.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState) { - NostrAccountDataSource.account = accountViewModel.account - NostrAccountDataSource.invalidateFilters() notifFeedContentState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 7809871d5..b3e5bfa0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -18,11 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.forks.forkFromVersion import com.vitorpamplona.quartz.experimental.forks.isForkFromAddressWithPubkey import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,12 +57,12 @@ class NotificationFeedFilter( override fun showHiddenKey(): Boolean = account.settings.defaultNotificationFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || account.settings.defaultNotificationFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultNotificationFollowList.value, followLists = account.liveNotificationFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 1335d383d..fd914f7c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -57,48 +56,46 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.BookmarkTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.NostrUserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.TabBookmarks -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.NostrUserProfileConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal.UserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.TabNotesConversations +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal.UserProfileConversationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.FollowersTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.NostrUserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.TabFollowers +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.FollowTabHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.NostrUserProfileFollowsUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.TabFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.NostrUserProfileGalleryFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal.UserProfileFollowsUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.TabGallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal.UserProfileGalleryFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.FollowedTagsTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.TabFollowedTags import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.NostrUserProfileMutualFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualConversations -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.NostrUserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.NostrUserProfileZapsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.TabReceivedZaps import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlinx.coroutines.Dispatchers @@ -141,99 +138,96 @@ fun PrepareViewModels( accountViewModel: AccountViewModel, nav: INav, ) { - val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel = + val followsFeedViewModel: UserProfileFollowsUserFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel", factory = - NostrUserProfileFollowsUserFeedViewModel.Factory( + UserProfileFollowsUserFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel = + val galleryFeedViewModel: UserProfileGalleryFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserGalleryFeedViewModel", factory = - NostrUserProfileGalleryFeedViewModel.Factory( + UserProfileGalleryFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel = + val followersFeedViewModel: UserProfileFollowersUserFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel", factory = - NostrUserProfileFollowersUserFeedViewModel.Factory( + UserProfileFollowersUserFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val appRecommendations: NostrUserAppRecommendationsFeedViewModel = + val appRecommendations: UserAppRecommendationsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel", - factory = - NostrUserAppRecommendationsFeedViewModel.Factory( - baseUser, - ), + factory = UserAppRecommendationsFeedViewModel.Factory(baseUser), ) - val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = + val zapFeedViewModel: UserProfileZapsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", factory = - NostrUserProfileZapsFeedViewModel.Factory( + UserProfileZapsFeedViewModel.Factory( baseUser, ), ) - val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = + val threadsViewModel: UserProfileNewThreadsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel", factory = - NostrUserProfileNewThreadsFeedViewModel.Factory( + UserProfileNewThreadsFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val repliesViewModel: NostrUserProfileConversationsFeedViewModel = + val repliesViewModel: UserProfileConversationsFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel", factory = - NostrUserProfileConversationsFeedViewModel.Factory( + UserProfileConversationsFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val mutualViewModel: NostrUserProfileMutualFeedViewModel = + val mutualViewModel: UserProfileMutualFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileMutualFeedViewModel", factory = - NostrUserProfileMutualFeedViewModel.Factory( + UserProfileMutualFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = + val bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel", factory = - NostrUserProfileBookmarksFeedViewModel.Factory( + UserProfileBookmarksFeedViewModel.Factory( baseUser, accountViewModel.account, ), ) - val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = + val reportsFeedViewModel: UserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", factory = - NostrUserProfileReportFeedViewModel.Factory( + UserProfileReportFeedViewModel.Factory( baseUser, ), ) @@ -258,49 +252,30 @@ fun PrepareViewModels( @Composable fun ProfileScreen( baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - NostrUserProfileDataSource.loadUserProfile(baseUser) + WatchLifecycleAndUpdateModel(threadsViewModel) + WatchLifecycleAndUpdateModel(repliesViewModel) + WatchLifecycleAndUpdateModel(mutualViewModel) + WatchLifecycleAndUpdateModel(followsFeedViewModel) + WatchLifecycleAndUpdateModel(followersFeedViewModel) + WatchLifecycleAndUpdateModel(appRecommendations) + WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) + WatchLifecycleAndUpdateModel(galleryFeedViewModel) + WatchLifecycleAndUpdateModel(reportsFeedViewModel) - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(accountViewModel) { - NostrUserProfileDataSource.start() - onDispose { - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Profidle Start") - NostrUserProfileDataSource.loadUserProfile(baseUser) - NostrUserProfileDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Profile Stop") - NostrUserProfileDataSource.loadUserProfile(null) - NostrUserProfileDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) RenderSurface { tabRowModifier: Modifier, pagerModifier: Modifier -> RenderScreen( @@ -400,16 +375,16 @@ private fun RenderScreen( baseUser: User, tabRowModifier: Modifier, pagerModifier: Modifier, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -454,15 +429,15 @@ private fun RenderScreen( private fun CreateAndRenderPages( page: Int, baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, - mutualViewModel: NostrUserProfileMutualFeedViewModel, - followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, - followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, - bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel, - galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel, - reportsFeedViewModel: NostrUserProfileReportFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, + mutualViewModel: UserProfileMutualFeedViewModel, + followsFeedViewModel: UserProfileFollowsUserFeedViewModel, + followersFeedViewModel: UserProfileFollowersUserFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, + bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + galleryFeedViewModel: UserProfileGalleryFeedViewModel, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -491,8 +466,8 @@ private fun CreateAndRenderPages( @Composable fun UpdateThreadsAndRepliesWhenBlockUnblock( baseUser: User, - threadsViewModel: NostrUserProfileNewThreadsFeedViewModel, - repliesViewModel: NostrUserProfileConversationsFeedViewModel, + threadsViewModel: UserProfileNewThreadsFeedViewModel, + repliesViewModel: UserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, ) { val isHidden by diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt index 5a43e0eff..62e29ff58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/TabBookmarks.kt @@ -30,10 +30,11 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal.UserProfileBookmarksFeedViewModel @Composable fun TabBookmarks( - feedViewModel: NostrUserProfileBookmarksFeedViewModel, + feedViewModel: UserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index 7270b22f3..ee4d7c5da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt index e88e52663..b5dcf09bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/NostrUserProfileBookmarksFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileBookmarksFeedViewModel( +class UserProfileBookmarksFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileBookmarksFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileBookmarksFeedViewModel = - NostrUserProfileBookmarksFeedViewModel(user, account) + UserProfileBookmarksFeedViewModel(user, account) as NostrUserProfileBookmarksFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt index 159415840..7ee96e0d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/TabNotesConversations.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal.UserProfileConversationsFeedViewModel @Composable fun TabNotesConversations( - feedViewModel: NostrUserProfileConversationsFeedViewModel, + feedViewModel: UserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 66ca7fd2f..4d8192419 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -18,12 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt index 0e27d9fef..6e7aa23ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/NostrUserProfileConversationsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.conversations.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileConversationsFeedViewModel( +class UserProfileConversationsFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileConversationsFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileConversationsFeedViewModel = - NostrUserProfileConversationsFeedViewModel(user, account) + UserProfileConversationsFeedViewModel(user, account) as NostrUserProfileConversationsFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt index f07dcfd24..cefee9954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -18,10 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -50,29 +52,31 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import kotlin.collections.flatten -object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { - var user: User? = null +// This allows multiple screen to be listening to tags, even the same tag +class UserProfileQueryState( + val user: User, +) - fun loadUserProfile(user: User?) { - this.user = user - } - - fun createUserInfoFilter() = - user?.let { +class UserProfileFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + fun createUserInfoFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(MetadataEvent.KIND), - authors = listOf(it.pubkeyHex), + authors = listOf(state.user.pubkeyHex), limit = 1, ), ) } - fun createUserPostsFilter() = - user?.let { + fun createUserPostsFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = @@ -90,14 +94,14 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { HighlightEvent.KIND, WikiNoteEvent.KIND, ), - authors = listOf(it.pubkeyHex), + authors = listOf(state.user.pubkeyHex), limit = 200, ), ) } - fun createUserPostsFilter2() = - user?.let { + fun createUserPostsFilter2(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = @@ -109,119 +113,132 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, ), - authors = listOf(it.pubkeyHex), + authors = listOf(state.user.pubkeyHex), limit = 50, ), ) } - fun createUserReceivedZapsFilter() = - user?.let { + fun createUserReceivedZapsFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(LnZapEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), + tags = mapOf("p" to listOf(state.user.pubkeyHex)), limit = 200, ), ) } - fun createFollowFilter() = - user?.let { + fun createFollowFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(ContactListEvent.KIND), - authors = listOf(it.pubkeyHex), + authors = listOf(state.user.pubkeyHex), limit = 1, ), ) } - fun createFollowersFilter() = - user?.let { + fun createFollowersFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), + tags = mapOf("p" to listOf(state.user.pubkeyHex)), ), ) } - fun createAcceptedAwardsFilter() = - user?.let { + fun createAcceptedAwardsFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(BadgeProfilesEvent.KIND), - authors = listOf(it.pubkeyHex), + authors = listOf(state.user.pubkeyHex), limit = 1, ), ) } - fun createBookmarksFilter() = - user?.let { + fun createBookmarksFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = - listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND), - authors = listOf(it.pubkeyHex), + listOf( + BookmarkListEvent.KIND, + PeopleListEvent.KIND, + AppRecommendationEvent.KIND, + ), + authors = listOf(state.user.pubkeyHex), limit = 100, ), ) } - fun createProfileGalleryFilter() = - user?.let { + fun createProfileGalleryFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = - listOf(ProfileGalleryEntryEvent.KIND, PictureEvent.KIND, VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND), - authors = listOf(it.pubkeyHex), + listOf( + ProfileGalleryEntryEvent.KIND, + PictureEvent.KIND, + VideoVerticalEvent.KIND, + VideoHorizontalEvent.KIND, + ), + authors = listOf(state.user.pubkeyHex), limit = 1000, ), ) } - fun createReceivedAwardsFilter() = - user?.let { + fun createReceivedAwardsFilter(keys: List) = + keys.map { state -> TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf(BadgeAwardEvent.KIND), - tags = mapOf("p" to listOf(it.pubkeyHex)), + tags = mapOf("p" to listOf(state.user.pubkeyHex)), limit = 20, ), ) } - val userInfoChannel = requestNewChannel() + val userInfoChannel = requestNewSubscription() + + override fun updateSubscriptions(keys1: Set) { + if (keys1.isEmpty()) return + + val keys = keys1.distinctBy { it.user.pubkeyHex } - override fun updateChannelFilters() { userInfoChannel.typedFilters = listOfNotNull( - createUserInfoFilter(), - createUserPostsFilter(), - createUserPostsFilter2(), - createProfileGalleryFilter(), - createFollowFilter(), - createFollowersFilter(), - createUserReceivedZapsFilter(), - createAcceptedAwardsFilter(), - createReceivedAwardsFilter(), - createBookmarksFilter(), - ).ifEmpty { null } + createUserInfoFilter(keys), + createUserPostsFilter(keys), + createUserPostsFilter2(keys), + createProfileGalleryFilter(keys), + createFollowFilter(keys), + createFollowersFilter(keys), + createUserReceivedZapsFilter(keys), + createAcceptedAwardsFilter(keys), + createReceivedAwardsFilter(keys), + createBookmarksFilter(keys), + ).flatten().ifEmpty { null } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt new file mode 100644 index 000000000..9eb70a2a8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription + +@Composable +fun UserProfileFilterAssemblerSubscription( + user: User, + assembler: UserProfileFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(user) { + UserProfileQueryState(user) + } + + KeyDataSourceSubscription(state, assembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt index 29165cb06..0eed892d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter class UserProfileFollowersFeedFilter( val user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index 2660475ed..44e502b56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/NostrUserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel -class NostrUserProfileFollowersUserFeedViewModel( +class UserProfileFollowersUserFeedViewModel( val user: User, val account: Account, ) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileFollowersUserFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileFollowersUserFeedViewModel = - NostrUserProfileFollowersUserFeedViewModel(user, account) + UserProfileFollowersUserFeedViewModel(user, account) as NostrUserProfileFollowersUserFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt index f6b9c0ff0..588049394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt @@ -18,11 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent class UserProfileFollowsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt index 2776ad572..caae8d779 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/NostrUserProfileFollowsUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel -class NostrUserProfileFollowsUserFeedViewModel( +class UserProfileFollowsUserFeedViewModel( val user: User, val account: Account, ) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileFollowsUserFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileFollowsUserFeedViewModel = - NostrUserProfileFollowsUserFeedViewModel(user, account) + UserProfileFollowsUserFeedViewModel(user, account) as NostrUserProfileFollowsUserFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt index d815efebd..767c010c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt @@ -24,11 +24,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.INav @@ -121,7 +120,7 @@ fun RedirectableGalleryCard( ) { if (sourceNote != galleryNote) { // preloads target note - val loadedSourceEvent by sourceNote.live().hasEvent.observeAsState(sourceNote.event != null) + EventFinderFilterAssemblerSubscription(sourceNote) } SensitivityWarning( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index efbc5f58f..299c6753c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -53,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVi import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.ClickableUrl @@ -78,7 +78,7 @@ fun GalleryThumbnail( nav: INav, ratio: Float = 1.0f, ) { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by observeNote(baseNote) val noteEvent = noteState?.note?.event ?: return val content = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt index 613c1360a..ddcbb97e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/TabGallery.kt @@ -29,10 +29,11 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal.UserProfileGalleryFeedViewModel @Composable fun TabGallery( - feedViewModel: NostrUserProfileGalleryFeedViewModel, + feedViewModel: UserProfileGalleryFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index fc9b69852..96e7bb827 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -18,12 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent @@ -37,8 +40,8 @@ class UserProfileGalleryFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery" override fun showHiddenKey(): Boolean = - account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultStoriesFollowList.value == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) @@ -74,7 +77,7 @@ class UserProfileGalleryFeedFilter( } fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt index e24d2b2f3..b11786212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/NostrUserProfileGalleryFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileGalleryFeedViewModel( +class UserProfileGalleryFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileGalleryFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileGalleryFeedViewModel = - NostrUserProfileGalleryFeedViewModel(user, account) + UserProfileGalleryFeedViewModel(user, account) as NostrUserProfileGalleryFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 3ae5407be..f28e304bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus @@ -57,7 +57,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.stringRes @@ -76,14 +76,12 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims @Composable fun DrawAdditionalInfo( baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val userState by baseUser.live().metadata.observeAsState() - val user = remember(userState) { userState?.user } ?: return - val tags = userState?.user?.info?.tags - + val userState by observeUser(baseUser) + val user = userState?.user ?: return val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current @@ -91,7 +89,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { CreateTextWithEmoji( text = it, - tags = tags, + tags = user.info?.tags ?: EmptyTagList, fontWeight = FontWeight.Bold, fontSize = 25.sp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index ff883a05c..f74bb6eaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,6 +40,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -51,13 +51,12 @@ fun DrawBanner( baseUser: User, accountViewModel: AccountViewModel, ) { - val userState by baseUser.live().metadata.observeAsState() - val banner = remember(userState) { userState?.user?.info?.banner } + val banner by observeUserBanner(baseUser) val clipboardManager = LocalClipboardManager.current var zoomImageDialogOpen by remember { mutableStateOf(false) } - if (!banner.isNullOrBlank()) { + if (banner.isNotBlank()) { AsyncImage( model = banner, contentDescription = stringRes(id = R.string.profile_image), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 39ddcb2c5..164227d42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.NostrUserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size100dp @@ -66,7 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.userProfileBorderModifier @Composable fun ProfileHeader( baseUser: User, - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, nav: INav, accountViewModel: AccountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt index 14aa1f004..68f0ce654 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/DisplayAppRecommendations.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.ui.stringRes @Composable fun DisplayAppRecommendations( - appRecommendations: NostrUserAppRecommendationsFeedViewModel, + appRecommendations: UserAppRecommendationsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt index fdc0bf6c4..a66adb593 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/NostrUserAppRecommendationsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserAppRecommendationsFeedViewModel.kt @@ -23,17 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserAppRecommendationsFeedViewModel( +class UserAppRecommendationsFeedViewModel( val user: User, ) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserAppRecommendationsFeedViewModel = - NostrUserAppRecommendationsFeedViewModel(user) + UserAppRecommendationsFeedViewModel(user) as NostrUserAppRecommendationsFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt index af5b729fc..9a3aa8ae0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileAppRecommendationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/UserProfileAppRecommendationsFeedFilter.kt @@ -18,11 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent class UserProfileAppRecommendationsFeedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 77d45ac85..07a25f53c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -35,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -47,7 +47,7 @@ fun WatchApp( baseApp: Note, nav: INav, ) { - val appState by baseApp.live().metadata.observeAsState() + val appState by observeNote(baseApp) var appLogo by remember(baseApp) { mutableStateOf(null) } var appName by remember(baseApp) { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 4b61a4793..e4b0a9060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,14 +40,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav @@ -93,13 +92,9 @@ private fun WatchAndRenderBadgeList( loadRobohash: Boolean, nav: INav, ) { - val badgeList by - note - .live() - .metadata - .map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() } - .distinctUntilChanged() - .observeAsState() + val badgeList by observeNoteEventAndMap(note) { event: BadgeProfilesEvent -> + event.badgeAwardEvents().toImmutableList() + } badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, loadRobohash, nav) } } @@ -147,10 +142,8 @@ private fun ObserveAndRenderBadge( loadRobohash: Boolean, nav: INav, ) { - val badgeAwardState by it.live().metadata.observeAsState() - val baseBadgeDefinition by - remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } } - + val badgeAwardState by observeNote(it) + val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull() baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, loadRobohash, nav, Size35dp) } } @@ -195,7 +188,7 @@ private fun WatchAndRenderBadgeImage( pictureModifier: Modifier, onClick: ((String) -> Unit)?, ) { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by observeNote(baseNote) val eventId = remember(noteState) { noteState?.note?.idHex } ?: return val image by remember(noteState) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt index c06a0d66d..0c591d405 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/TabMutualConversations.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel @Composable fun TabMutualConversations( - feedViewModel: NostrUserProfileMutualFeedViewModel, + feedViewModel: UserProfileMutualFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index bfa01ace9..1cc87e9f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -18,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt index f234e96e7..68d9f37e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/NostrUserProfileMutualFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileMutualFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileMutualFeedViewModel( +class UserProfileMutualFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileMutualFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileMutualFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileMutualFeedViewModel = - NostrUserProfileMutualFeedViewModel(user, account) + UserProfileMutualFeedViewModel(user, account) as NostrUserProfileMutualFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index afb8cbb4d..a16db224f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel @Composable fun TabNotesNewThreads( - feedViewModel: NostrUserProfileNewThreadsFeedViewModel, + feedViewModel: UserProfileNewThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 6e5676064..3b9cc0d1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -18,13 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt index 1032d0bef..8de906d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/NostrUserProfileNewThreadsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadsFeedViewModel.kt @@ -18,16 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileNewThreadsFeedViewModel( +class UserProfileNewThreadsFeedViewModel( val user: User, val account: Account, ) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { @@ -36,7 +35,7 @@ class NostrUserProfileNewThreadsFeedViewModel( val account: Account, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrUserProfileNewThreadsFeedViewModel = - NostrUserProfileNewThreadsFeedViewModel(user, account) + UserProfileNewThreadsFeedViewModel(user, account) as NostrUserProfileNewThreadsFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt index 8f9933148..35a41495e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt index 784d86f45..e4e4a848e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -28,11 +28,12 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel @Composable fun TabReports( baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, + feedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt index 78cd74172..137f8d934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt @@ -25,11 +25,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel @Composable fun WatchReportsAndUpdateFeed( baseUser: User, - feedViewModel: NostrUserProfileReportFeedViewModel, + feedViewModel: UserProfileReportFeedViewModel, ) { val userState by baseUser.live().reports.observeAsState() LaunchedEffect(userState) { feedViewModel.invalidateData() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt index 4aee303be..4524f533a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/NostrUserProfileReportFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt @@ -18,20 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -class NostrUserProfileReportFeedViewModel( +class UserProfileReportFeedViewModel( val user: User, ) : FeedViewModel(UserProfileReportsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel = NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel + override fun create(modelClass: Class): NostrUserProfileReportFeedViewModel = UserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt index f44d734f2..9768ef218 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt @@ -18,10 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip56Reports.ReportEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt index 0355ca81a..08d6e0462 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/TabReceivedZaps.kt @@ -27,11 +27,12 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel @Composable fun TabReceivedZaps( baseUser: User, - zapFeedViewModel: NostrUserProfileZapsFeedViewModel, + zapFeedViewModel: UserProfileZapsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt index cd2fec5d0..38f54d328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/WatchZapsAndUpdateFeed.kt @@ -25,11 +25,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel @Composable fun WatchZapsAndUpdateFeed( baseUser: User, - feedViewModel: NostrUserProfileZapsFeedViewModel, + feedViewModel: UserProfileZapsFeedViewModel, ) { val userState by baseUser.live().zaps.observeAsState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt index ebb0d7567..5ef0fb4ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedFilter.kt @@ -18,10 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt index f1d8a66ac..f05ad0974 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/NostrUserProfileZapsFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsFeedViewModel.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps +package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.LnZapFeedViewModel -class NostrUserProfileZapsFeedViewModel( +class UserProfileZapsFeedViewModel( user: User, ) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { class Factory( val user: User, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel = NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel = UserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt index aa0a44e14..5caa05ecd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/redirect/LoadRedirectScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -29,7 +29,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,9 +38,11 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Nav import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -91,7 +92,7 @@ fun LoadRedirectScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by baseNote.live().metadata.observeAsState() + val noteState by observeNote(baseNote) LaunchedEffect(key1 = noteState) { val note = noteState?.note ?: return@LaunchedEffect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt index 3ae9a888c..70122e622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn +package com.vitorpamplona.amethyst.ui.screen.loggedIn.report import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -60,6 +60,9 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.WarningColor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt index feb746b93..b2d34926b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddInboxRelayForSearchCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.note.elements +package com.vitorpamplona.amethyst.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,12 +38,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.AddSearchRelayListDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BigPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -106,8 +105,7 @@ fun ObserveRelayListForSearch( accountViewModel, ) { relayList -> if (relayList != null) { - val relayListNoteState by relayList.live().metadata.observeAsState() - val relayListEvent = relayListNoteState?.note?.event as? SearchRelayListEvent + val relayListEvent by observeNoteEvent(relayList) inner(relayListEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index daee2f64c..c1e0580e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search +package com.vitorpamplona.amethyst.ui.screen.loggedIn.search import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -50,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 160659ce6..b21f7fb20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search -import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -29,84 +28,102 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update @Stable +@OptIn(FlowPreview::class) class SearchBarViewModel( val account: Account, -) : ViewModel() { +) : ViewModel(), + InvalidatableContent { val focusRequester = FocusRequester() var searchValue by mutableStateOf("") - private var _searchResultsUsers = MutableStateFlow>(emptyList()) - private var _searchResultsNotes = MutableStateFlow>(emptyList()) - private var _searchResultsChannels = MutableStateFlow>(emptyList()) - private var _hashtagResults = MutableStateFlow>(emptyList()) + val invalidations = MutableStateFlow(0) + val searchValueFlow = MutableStateFlow("") - val searchResultsUsers = _searchResultsUsers.asStateFlow() - val searchResultsNotes = _searchResultsNotes.asStateFlow() - val searchResultsChannels = _searchResultsChannels.asStateFlow() - val hashtagResults = _hashtagResults.asStateFlow() + val searchTerm = + searchValueFlow + .debounce(300) + .distinctUntilChanged() + .onEach(::updateDataSource) + .stateIn(viewModelScope, SharingStarted.Eagerly, searchValue) - val isSearching by derivedStateOf { searchValue.isNotBlank() } + val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue)) + + val searchResultsUsers = + combine(searchValueFlow.debounce(100), invalidations.debounce(100)) { term, version -> + logTime("SearchBarViewModel findUsersStartingWith") { + LocalCache.findUsersStartingWith(term, account) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsNotes = + combine(searchValueFlow.debounce(100), invalidations) { term, version -> + logTime("SearchBarViewModel findNotesStartingWith") { + LocalCache + .findNotesStartingWith(term, account) + .sortedWith(DefaultFeedOrder) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val searchResultsChannels = + combine(searchValueFlow.debounce(100), invalidations) { term, version -> + logTime("SearchBarViewModel findChannelsStartingWith") { + LocalCache.findChannelsStartingWith(term) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + val hashtagResults = + combine(searchValueFlow.debounce(100), invalidations) { term, version -> + logTime("SearchBarViewModel findHashtags") { + findHashtags(term) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + + override val isRefreshing = derivedStateOf { searchValue.isNotBlank() } + + override fun invalidateData(ignoreIfDoing: Boolean) { + // force new query + invalidations.update { it + 1 } + } fun updateSearchValue(newValue: String) { searchValue = newValue + searchValueFlow.tryEmit(newValue) } - private suspend fun runSearch() { - if (searchValue.isBlank()) { - _hashtagResults.value = emptyList() - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - return + fun clear() = updateSearchValue("") + + fun updateDataSource(searchTerm: String) { + if (searchTerm.isBlank()) { + searchDataSourceState.searchQuery.tryEmit("") + } else { + searchDataSourceState.searchQuery.tryEmit(searchTerm) } - - _hashtagResults.emit(findHashtags(searchValue)) - _searchResultsUsers.emit( - LocalCache.findUsersStartingWith(searchValue, account), - ) - _searchResultsNotes.emit( - LocalCache - .findNotesStartingWith(searchValue, account) - .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - .reversed(), - ) - _searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue)) - } - - fun clear() { - searchValue = "" - _searchResultsUsers.value = emptyList() - _searchResultsChannels.value = emptyList() - _searchResultsNotes.value = emptyList() - _searchResultsChannels.value = emptyList() - } - - private val bundler = BundledUpdate(250, Dispatchers.IO) - - fun invalidateData() { - bundler.invalidate { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - runSearch() - } - } - - override fun onCleared() { - bundler.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() } fun isSearchingFun() = searchValue.isNotBlank() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 55a13d3dc..016cb69e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -43,28 +43,23 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route @@ -72,9 +67,7 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -82,16 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable fun SearchScreen( @@ -116,27 +100,7 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - - WatchAccountForSearchScreen(accountViewModel) - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Search Start") - NostrSearchEventOrUserDataSource.start() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Search Stop") - NostrSearchEventOrUserDataSource.clear() - NostrSearchEventOrUserDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(searchBarViewModel) val listState = rememberLazyListState() @@ -165,13 +129,6 @@ fun SearchScreen( } } -@Composable -fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - launch(Dispatchers.IO) { NostrSearchEventOrUserDataSource.start() } - } -} - @OptIn(FlowPreview::class) @Composable private fun SearchBar( @@ -179,48 +136,30 @@ private fun SearchBar( listState: LazyListState, nav: INav, ) { - val scope = rememberCoroutineScope() - - // Create a channel for processing search queries. - val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } + TextSearchDataSourceSubscription(searchBarViewModel) LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - checkNotInMainThread() - - if (searchBarViewModel.isSearchingFun()) { - searchBarViewModel.invalidateData() - } + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearchingFun()) { + searchBarViewModel.invalidateData() } } } - LaunchedEffect(Unit) { - // Wait for text changes to stop for 300 ms before firing off search. - withContext(Dispatchers.IO) { - searchTextChanges - .receiveAsFlow() - .filter { it.isNotBlank() } - .distinctUntilChanged() - .debounce(300) - .collectLatest { - if (it.length >= 2) { - NostrSearchEventOrUserDataSource.search(it.trim()) - } + AnimateOnNewSearch(searchBarViewModel, listState) - searchBarViewModel.invalidateData() + SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) +} - // makes sure to show the top of the search - launch(Dispatchers.Main) { listState.animateScrollToItem(0) } - } - } - } +@Composable +fun AnimateOnNewSearch( + searchBarViewModel: SearchBarViewModel, + listState: LazyListState, +) { + val searchTerm by searchBarViewModel.searchTerm.collectAsStateWithLifecycle() - DisposableEffect(Unit) { onDispose { NostrSearchEventOrUserDataSource.clear() } } - - SearchTextField(searchBarViewModel, modifier = Modifier.statusBarsPadding()) { - scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) } + LaunchedEffect(searchTerm) { + listState.animateScrollToItem(0) } } @@ -228,7 +167,6 @@ private fun SearchBar( private fun SearchTextField( searchBarViewModel: SearchBarViewModel, modifier: Modifier, - onTextChanges: (String) -> Unit, ) { Row( modifier = modifier.padding(10.dp).fillMaxWidth(), @@ -239,7 +177,6 @@ private fun SearchTextField( value = searchBarViewModel.searchValue, onValueChange = { searchBarViewModel.updateSearchValue(it) - onTextChanges(it) }, shape = RoundedCornerShape(25.dp), keyboardOptions = @@ -259,11 +196,10 @@ private fun SearchTextField( ) }, trailingIcon = { - if (searchBarViewModel.isSearching) { + if (searchBarViewModel.isRefreshing.value) { IconButton( onClick = { searchBarViewModel.clear() - NostrSearchEventOrUserDataSource.clear() }, ) { ClearTextIcon() @@ -287,7 +223,7 @@ private fun DisplaySearchResults( nav: INav, accountViewModel: AccountViewModel, ) { - if (!searchBarViewModel.isSearching) { + if (!searchBarViewModel.isRefreshing.value) { return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index efcb0a3ed..a44bed33b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -56,11 +56,11 @@ import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.mockSharedPreferencesViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 83877c3d4..cdec89168 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -68,14 +68,15 @@ import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.elements.AddButton -import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding @@ -91,19 +92,19 @@ fun SecurityFiltersScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = + val hiddenFeedViewModel: HiddenAccountsFeedViewModel = viewModel( - factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account), + factory = HiddenAccountsFeedViewModel.Factory(accountViewModel.account), ) - val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = + val hiddenWordsFeedViewModel: HiddenWordsFeedViewModel = viewModel( - factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account), + factory = HiddenWordsFeedViewModel.Factory(accountViewModel.account), ) - val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = + val spammerFeedViewModel: SpammerAccountsFeedViewModel = viewModel( - factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account), + factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account), ) WatchAccountAndBlockList(accountViewModel = accountViewModel) { @@ -124,9 +125,9 @@ fun SecurityFiltersScreen( @OptIn(ExperimentalFoundationApi::class) @Composable fun SecurityFiltersScreen( - hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, - spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, + hiddenFeedViewModel: HiddenAccountsFeedViewModel, + hiddenWordsViewModel: HiddenWordsFeedViewModel, + spammerFeedViewModel: SpammerAccountsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -221,7 +222,7 @@ fun SecurityFiltersScreen( @Composable private fun HiddenWordsFeed( - hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + hiddenWordsViewModel: HiddenWordsFeedViewModel, accountViewModel: AccountViewModel, ) { RefresheableBox(hiddenWordsViewModel, false) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 57885dca6..5df526030 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -25,13 +25,10 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter -import com.vitorpamplona.amethyst.ui.dal.HiddenWordsFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate @@ -43,18 +40,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -class NostrHiddenWordsFeedViewModel( - val account: Account, -) : StringFeedViewModel( - HiddenWordsFeedFilter(account), - ) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel = NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel - } -} - @Stable open class StringFeedViewModel( val dataSource: FeedFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 1a04da166..f5d44667f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal import android.util.Log import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import kotlinx.coroutines.CancellationException class HiddenAccountsFeedFilter( @@ -44,25 +45,3 @@ class HiddenAccountsFeedFilter( } } } - -class HiddenWordsFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex - - override fun showHiddenKey(): Boolean = true - - override fun feed(): List = - account.flowHiddenUsers.value.hiddenWords - .toList() -} - -class SpammerAccountsFeedFilter( - val account: Account, -) : FeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex - - override fun showHiddenKey(): Boolean = true - - override fun feed(): List = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt new file mode 100644 index 000000000..ee477764e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class HiddenAccountsFeedViewModel( + val account: Account, +) : UserFeedViewModel(HiddenAccountsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHiddenAccountsFeedViewModel = HiddenAccountsFeedViewModel(account) as NostrHiddenAccountsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt new file mode 100644 index 000000000..bc32f3e05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedFilter.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class HiddenWordsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = + account.flowHiddenUsers.value.hiddenWords + .toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt new file mode 100644 index 000000000..545efda1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenWordsFeedViewModel.kt @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.StringFeedViewModel + +class HiddenWordsFeedViewModel( + val account: Account, +) : StringFeedViewModel( + HiddenWordsFeedFilter(account), + ) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel = HiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt new file mode 100644 index 000000000..ec04b7136 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedFilter.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class SpammerAccountsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt new file mode 100644 index 000000000..cd7290181 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/SpammerAccountsFeedViewModel.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel + +class SpammerAccountsFeedViewModel( + val account: Account, +) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrSpammerAccountsFeedViewModel = SpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index bffcd62bc..660a5d6c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -144,12 +144,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay -import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt index 5bb86f7e5..45e4a0f12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -24,21 +24,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes @Composable @@ -49,48 +46,19 @@ fun ThreadScreen( ) { if (noteId == null) return - val lifeCycleOwner = LocalLifecycleOwner.current - - val feedViewModel: NostrThreadFeedViewModel = + val feedViewModel: ThreadFeedViewModel = viewModel( key = noteId + "NostrThreadFeedViewModel", - factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId), + factory = ThreadFeedViewModel.Factory(accountViewModel.account, noteId), ) - NostrThreadDataSource.loadThread(noteId) - - DisposableEffect(noteId) { - feedViewModel.invalidateData(true) - onDispose { - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Thread Start") - NostrThreadDataSource.loadThread(noteId) - NostrThreadDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Thread Stop") - NostrThreadDataSource.loadThread(null) - NostrThreadDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + WatchLifecycleAndUpdateModel(feedViewModel) + ThreadFilterAssemblerSubscription(noteId, accountViewModel.dataSources().thread) LoadNote(noteId, accountViewModel) { if (it != null) { // this will force loading every post from this thread. - val metadata = it.live().metadata.observeAsState() + EventFinderFilterAssemblerSubscription(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt new file mode 100644 index 000000000..1e0d39a00 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/LevelFeedViewModel.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal + +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.ThreadLevelCalculator +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.amethyst.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +abstract class LevelFeedViewModel( + localFilter: FeedFilter, +) : FeedViewModel(localFilter) { + var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) + + val hasDragged = mutableStateOf(false) + + val selectedIDHex = + llState.interactionSource.interactions + .onEach { + if (it is DragInteraction.Start) { + hasDragged.value = true + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val levelCacheFlow: StateFlow> = + feedState.feedContent + .transformLatest { feed -> + emitAll( + if (feed is FeedState.Loaded) { + feed.feed.map { + val cache = mutableMapOf() + it.list.forEach { + ThreadLevelCalculator.replyLevel(it, cache) + } + cache + } + } else { + MutableStateFlow(mapOf()) + }, + ) + }.flowOn(Dispatchers.Default) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + mapOf(), + ) + + fun levelFlowForItem(note: Note) = + levelCacheFlow + .map { + it[note] ?: 0 + }.distinctUntilChanged() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt index bc80e6fb2..04e2df687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.Account @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.LevelSignature import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.model.ThreadLevelCalculator +import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt new file mode 100644 index 000000000..ba5a7dee2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedViewModel.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account + +class ThreadFeedViewModel( + account: Account, + noteId: String, +) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) { + class Factory( + val account: Account, + val noteId: String, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrThreadFeedViewModel = ThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt new file mode 100644 index 000000000..8a35dfefe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.ThreadAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator +import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.collections.flatten +import kotlin.collections.ifEmpty + +// This allows multiple screen to be listening to tags, even the same tag +class ThreadQueryState( + val eventId: HexKey, +) + +class ThreadFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { + fun createLoadEventsIfNotLoadedFilter(keys: Set): List { + if (keys.isEmpty()) return emptyList() + + val uniqueThreadIds = keys.mapTo(mutableSetOf()) { it.eventId } + + val branches = + uniqueThreadIds.map { + ThreadAssembler().findThreadFor(it) ?: return emptyList() + } + + val eventsToLoad = + branches + .mapNotNull { + it.allNotes + .filter { it.event == null } + .map { it.idHex } + .toSet() + .ifEmpty { null } + }.flatten() + .distinct() + + val addressRoots = + branches + .mapNotNull { + if (it.root is AddressableNote) it.root.idHex else null + }.distinct() + + val eventRoots = + branches + .mapNotNull { + if (it.root !is AddressableNote) it.root.idHex else it.root.event?.id + }.distinct() + + return listOfNotNull( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter(tags = mapOf("e" to eventRoots)), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter(tags = mapOf("a" to addressRoots)), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter(tags = mapOf("E" to eventRoots)), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter(tags = mapOf("A" to addressRoots)), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter(ids = eventsToLoad), + ), + ) + } + + val loadEventsChannel = + requestNewSubscription { _, _ -> + // Many relays operate with limits in the amount of filters. + // As information comes, the filters will be rotated to get more data. + invalidateFilters() + } + + override fun updateSubscriptions(keys: Set) { + loadEventsChannel.typedFilters = createLoadEventsIfNotLoadedFilter(keys).ifEmpty { null } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt new file mode 100644 index 000000000..1cccbe973 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Composable +fun ThreadFilterAssemblerSubscription( + eventId: HexKey, + filterAssembler: ThreadFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(eventId) { + ThreadQueryState(eventId) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 934fb3f7d..3a6201575 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,14 +50,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -69,8 +64,10 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route @@ -89,7 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AuthorInfoVideoFeed import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -125,22 +122,9 @@ fun VideoScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val lifeCycleOwner = LocalLifecycleOwner.current - + WatchLifecycleAndUpdateModel(videoFeedContentState) WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel) - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Video Start") - NostrVideoDataSource.start() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } + VideoFilterAssemblerSubscription(accountViewModel.dataSources().video, accountViewModel) DisappearingScaffold( isInvertedLayout = false, @@ -183,7 +167,6 @@ fun WatchAccountForVideoScreen( val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState, hiddenUsers) { - NostrVideoDataSource.resetFilters() videoFeedContentState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index 565b2ba64..9cda46566 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -18,14 +18,17 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isImageOrVideoUrl +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent @@ -42,8 +45,8 @@ class VideoFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value override fun showHiddenKey(): Boolean = - account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultStoriesFollowList.value == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) @@ -76,12 +79,12 @@ class VideoFeedFilter( val isSupportedMimeType = mimeType?.let { SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it) } ?: false - return urls.isNotEmpty() && (urls.any { isImageOrVideoUrl(it) } || isSupportedMimeType) + return urls.isNotEmpty() && (urls.any { RichTextParser.Companion.isImageOrVideoUrl(it) } || isSupportedMimeType) } fun acceptableiMetas(iMetas: List): Boolean = iMetas.any { - !it.url.contains("youtu.be") && (isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType))) + !it.url.contains("youtu.be") && (RichTextParser.Companion.isImageOrVideoUrl(it.url) || (it.mimeType == null || SUPPORTED_VIDEO_FEED_MIME_TYPES_SET.contains(it.mimeType))) } fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) @@ -104,7 +107,12 @@ class VideoFeedFilter( (noteEvent is FileHeaderEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || - (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + ( + noteEvent is FileStorageHeaderEvent && + noteEvent.isOneOf( + SUPPORTED_VIDEO_FEED_MIME_TYPES_SET, + ) + ) || noteEvent is PictureEvent ) && params.match(noteEvent) && @@ -112,7 +120,7 @@ class VideoFeedFilter( } fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.create( + FilterByListParams.Companion.create( userHex = account.userProfile().pubkeyHex, selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index f0a0fa46c..0a8b0d02c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -18,13 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.QueryBasedSubscriptionOrchestrator import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.FeedType +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent @@ -32,41 +35,28 @@ import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlin.collections.flatten val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif") val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() -object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { - lateinit var account: Account +// This allows multiple screen to be listening to tags, even the same tag +class VideoQueryState( + val account: Account, + val scope: CoroutineScope, +) - val scope = Amethyst.instance.applicationIOScope +class VideoFilterAssembler( + client: NostrClient, +) : QueryBasedSubscriptionOrchestrator(client) { val latestEOSEs = EOSEAccount() - var job: Job? = null - - override fun start() { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - account.liveStoriesFollowLists.collect { - if (this@NostrVideoDataSource::account.isInitialized) { - invalidateFilters() - } - } - } - super.start() - } - - override fun stop() { - super.stop() - job?.cancel() - } - - fun createContextualFilter(): List { - val follows = account.liveStoriesListAuthorsPerRelay.value + fun createContextualFilter(key: VideoQueryState): List { + val follows = key.account.liveStoriesListAuthorsPerRelay.value val types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS) @@ -79,9 +69,9 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), limit = 200, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), @@ -94,18 +84,18 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { limit = 200, tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), ) } - fun createFollowTagsFilter(): List { + fun createFollowTagsFilter(key: VideoQueryState): List { val hashToLoad = - account.liveStoriesFollowLists.value + key.account.liveStoriesFollowLists.value ?.hashtags ?.toList() ?: return emptyList() @@ -125,9 +115,9 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { tags = mapOf("t" to hashtags), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), @@ -143,18 +133,18 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { ), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), ) } - fun createFollowGeohashesFilter(): List { + fun createFollowGeohashesFilter(key: VideoQueryState): List { val hashToLoad = - account.liveStoriesFollowLists.value + key.account.liveStoriesFollowLists.value ?.geotags ?.toList() ?: return emptyList() @@ -171,9 +161,9 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { tags = mapOf("g" to geoHashes), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), @@ -189,31 +179,79 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { ), limit = 100, since = - latestEOSEs.users[account.userProfile()] + latestEOSEs.users[key.account.userProfile()] ?.followList - ?.get(account.settings.defaultStoriesFollowList.value) + ?.get(key.account.settings.defaultStoriesFollowList.value) ?.relayList, ), ), ) } - val videoFeedChannel = - requestNewChannel { time, relayUrl -> + fun mergeAllFilters(key: VideoQueryState): List? = + listOfNotNull( + createContextualFilter(key), + createFollowTagsFilter(key), + createFollowGeohashesFilter(key), + ).flatten().ifEmpty { null } + + val userJobMap = mutableMapOf() + val userSubscriptionMap = mutableMapOf() + + fun newSub(key: VideoQueryState): Subscription { + userJobMap[key.account.userProfile()]?.cancel() + userJobMap[key.account.userProfile()] = + key.scope.launch(Dispatchers.Default) { + key.account.liveStoriesFollowLists.collect { + invalidateFilters() + } + } + + return requestNewSubscription { time, relayUrl -> latestEOSEs.addOrUpdate( - account.userProfile(), - account.settings.defaultStoriesFollowList.value, + key.account.userProfile(), + key.account.settings.defaultStoriesFollowList.value, relayUrl, time, ) } + } - override fun updateChannelFilters() { - videoFeedChannel.typedFilters = - listOfNotNull( - createContextualFilter(), - createFollowTagsFilter(), - createFollowGeohashesFilter(), - ).flatten().ifEmpty { null } + fun endSub( + key: User, + subId: String, + ) { + dismissSubscription(subId) + userJobMap[key]?.cancel() + } + + fun findOrCreateSubFor(key: VideoQueryState): Subscription { + var subId = userSubscriptionMap[key.account.userProfile()] + return if (subId == null) { + newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } else { + getSub(subId) ?: newSub(key).also { userSubscriptionMap[key.account.userProfile()] = it.id } + } + } + + // One sub per subscribed account + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.account } + + val updated = mutableSetOf() + + uniqueSubscribedAccounts.forEach { + val user = it.account.userProfile() + val sub = findOrCreateSubFor(it) + sub.typedFilters = mergeAllFilters(it) + + updated.add(user) + } + + userSubscriptionMap.forEach { + if (it.key !in updated) { + endSub(it.key, it.value) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt new file mode 100644 index 000000000..84dfa1057 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun VideoFilterAssemblerSubscription( + filterAssembler: VideoFilterAssembler, + accountViewModel: AccountViewModel, +) { + // different screens get different states + // even if they are tracking the same tag. + val state = + remember(accountViewModel.account) { + VideoQueryState(accountViewModel.account, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt index 18c6e19ea..7e50595d4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/zaps/UserProfileZapsFeedFilterTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.zaps import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import io.mockk.every diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt index 2631384b5..3c894a406 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt @@ -20,16 +20,17 @@ */ package com.vitorpamplona.ammolite.relays -import android.system.Os.close import android.util.Log -import androidx.core.app.PendingIntentCompat.send import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.RelayState import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.UUID @@ -37,26 +38,28 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** - * The Nostr Client manages multiple personae the user may switch between. Events are received and - * published through multiple relays. Events are stored with their respective persona. + * The Nostr Client manages a relay pool */ class NostrClient( private val websocketBuilder: WebsocketBuilderFactory, + private val listenerScope: CoroutineScope = + CoroutineScope( + Dispatchers.Default + SupervisorJob() + + CoroutineExceptionHandler { _, throwable -> + Log.e("NostrClient", "Caught exception: ${throwable.message}", throwable) + }, + ), ) : RelayPool.Listener { private val relayPool: RelayPool = RelayPool() - private val subscriptions: MutableSubscriptionManager = MutableSubscriptionManager() - + private val activeSubscriptions: MutableSubscriptionManager = MutableSubscriptionManager() private var listeners = setOf() - private var relays = emptyArray() - fun buildRelay(it: RelaySetupInfoToConnect): Relay = Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes, websocketBuilder, subscriptions) + fun buildRelay(it: RelaySetupInfoToConnect): Relay = Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes, websocketBuilder, activeSubscriptions) fun getRelay(url: String): Relay? = relayPool.getRelay(url) - fun reconnect() { - // Reconnects all relays that may have disconnected - relayPool.requestAndWatch() - } + // Reconnects all relays that may have disconnected + fun reconnect() = relayPool.requestAndWatch() @Synchronized fun reconnect( @@ -68,47 +71,42 @@ class NostrClient( if (onlyIfChanged) { if (!isSameRelaySetConfig(relays)) { - if (this.relays.isNotEmpty()) { + if (this.relayPool.availableRelays() > 0) { relayPool.disconnect() relayPool.unregister(this) relayPool.unloadRelays() } if (relays != null) { - val newRelays = relays.map(::buildRelay) relayPool.register(this) - relayPool.loadRelays(newRelays) + relayPool.loadRelays(relays.map(::buildRelay)) relayPool.requestAndWatch() - this.relays = newRelays.toTypedArray() } } else { // Reconnects all relays that may have disconnected relayPool.requestAndWatch() } } else { - if (this.relays.isNotEmpty()) { + if (this.relayPool.availableRelays() > 0) { relayPool.disconnect() relayPool.unregister(this) relayPool.unloadRelays() } if (relays != null) { - val newRelays = relays.map(::buildRelay) relayPool.register(this) - relayPool.loadRelays(newRelays) + relayPool.loadRelays(relays.map(::buildRelay)) relayPool.requestAndWatch() - this.relays = newRelays.toTypedArray() } } } fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relays.size != newRelayConfig?.size) return false + if (relayPool.availableRelays() != newRelayConfig?.size) return false - relays.forEach { oldRelayInfo -> - val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false - - if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false + newRelayConfig.forEach { + val relay = relayPool.getRelay(it.url) ?: return false + if (!relay.isSameRelayConfig(it)) return false } return true @@ -120,7 +118,7 @@ class NostrClient( ) { checkNotInMainThread() - subscriptions.add(subscriptionId, filters) + activeSubscriptions.add(subscriptionId, filters) relayPool.sendFilter(subscriptionId, filters) } @@ -148,7 +146,7 @@ class NostrClient( }, ) - subscriptions.add(subscriptionId, filters) + activeSubscriptions.add(subscriptionId, filters) relayPool.sendFilter(subscriptionId, filters) } @@ -255,7 +253,7 @@ class NostrClient( ) { checkNotInMainThread() - subscriptions.add(subscriptionId, filters) + activeSubscriptions.add(subscriptionId, filters) relayPool.connectAndSendFiltersIfDisconnected() } @@ -311,10 +309,10 @@ class NostrClient( fun close(subscriptionId: String) { relayPool.close(subscriptionId) - subscriptions.remove(subscriptionId) + activeSubscriptions.remove(subscriptionId) } - fun isActive(subscriptionId: String): Boolean = subscriptions.isActive(subscriptionId) + fun isActive(subscriptionId: String): Boolean = activeSubscriptions.isActive(subscriptionId) @OptIn(DelicateCoroutinesApi::class) override fun onEvent( @@ -325,7 +323,7 @@ class NostrClient( ) { // Releases the Web thread for the new payload. // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) } } } @@ -334,14 +332,18 @@ class NostrClient( relay: Relay, subscriptionId: String, ) { - listeners.forEach { it.onEOSE(relay, subscriptionId) } + listenerScope.launch { + listeners.forEach { it.onEOSE(relay, subscriptionId) } + } } override fun onRelayStateChange( type: RelayState, relay: Relay, ) { - listeners.forEach { it.onRelayStateChange(type, relay) } + listenerScope.launch { + listeners.forEach { it.onRelayStateChange(type, relay) } + } } @OptIn(DelicateCoroutinesApi::class) @@ -353,7 +355,7 @@ class NostrClient( ) { // Releases the Web thread for the new payload. // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onSendResponse(eventId, success, message, relay) } } } @@ -365,7 +367,7 @@ class NostrClient( ) { // Releases the Web thread for the new payload. // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { listeners.forEach { it.onAuth(relay, challenge) } } + listenerScope.launch { listeners.forEach { it.onAuth(relay, challenge) } } } @OptIn(DelicateCoroutinesApi::class) @@ -375,7 +377,7 @@ class NostrClient( ) { // Releases the Web thread for the new payload. // May need to add a processing queue if processing new events become too costly. - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onNotify(relay, description) } } } @@ -386,7 +388,7 @@ class NostrClient( msg: String, success: Boolean, ) { - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onSend(relay, msg, success) } } } @@ -396,7 +398,7 @@ class NostrClient( relay: Relay, event: Event, ) { - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onBeforeSend(relay, event) } } } @@ -407,7 +409,7 @@ class NostrClient( subscriptionId: String, relay: Relay, ) { - GlobalScope.launch(Dispatchers.Default) { + listenerScope.launch { listeners.forEach { it.onError(error, subscriptionId, relay) } } } @@ -422,9 +424,9 @@ class NostrClient( listeners = listeners.minus(listener) } - fun allSubscriptions(): Map> = subscriptions.allSubscriptions() + fun allSubscriptions(): Map> = activeSubscriptions.allSubscriptions() - fun getSubscriptionFilters(subId: String): List = subscriptions.getSubscriptionFilters(subId) + fun getSubscriptionFilters(subId: String): List = activeSubscriptions.getSubscriptionFilters(subId) fun connectedRelays() = relayPool.connectedRelays() diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt deleted file mode 100644 index f585f438f..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrDataSource.kt +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.ammolite.relays - -import android.util.Log -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean - -abstract class NostrDataSource( - val client: NostrClient, - val debugName: String, -) { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private var subscriptions = mapOf() - - data class Counter( - val subscriptionId: String, - val eventKind: Int, - var counter: Int, - ) - - private var eventCounter = mapOf() - var changingFilters = AtomicBoolean() - - private var active: Boolean = false - - fun printCounter() { - eventCounter.forEach { - Log.d( - "STATE DUMP ${this.javaClass.simpleName}", - "Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}", - ) - } - } - - fun hashCodeFields( - str1: String, - str2: Int, - ): Int = 31 * str1.hashCode() + str2.hashCode() - - private val clientListener = - object : NostrClient.Listener { - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - afterEOSE: Boolean, - ) { - if (subscriptions.containsKey(subscriptionId)) { - val key = hashCodeFields(subscriptionId, event.kind) - val keyValue = eventCounter[key] - if (keyValue != null) { - keyValue.counter++ - } else { - eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1)) - } - - // Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: $subscriptionId ${event.kind} ") - - consume(event, relay) - if (afterEOSE) { - markAsEOSE(subscriptionId, relay) - } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - ) { - if (subscriptions.containsKey(subscriptionId)) { - markAsEOSE(subscriptionId, relay) - } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) {} - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (success) { - markAsSeenOnRelay(eventId, relay) - } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - auth(relay, challenge) - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - notify(relay, description) - } - } - - init { - Log.d("DataSource", "${this.javaClass.simpleName} Subscribe") - client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d("DataSource", "${this.javaClass.simpleName} Unsubscribe") - stop() - client.unsubscribe(clientListener) - scope.cancel() - bundler.cancel() - } - - open fun start() { - Log.d("DataSource", "${this.javaClass.simpleName} Start") - active = true - resetFilters() - } - - open fun startSync() { - Log.d("DataSource", "${this.javaClass.simpleName} Start") - active = true - resetFiltersSuspend() - } - - @OptIn(DelicateCoroutinesApi::class) - open fun stop() { - active = false - Log.d("DataSource", "${this.javaClass.simpleName} Stop") - - GlobalScope.launch(Dispatchers.IO) { - subscriptions.values.forEach { subscription -> - client.close(subscription.id) - subscription.typedFilters = null - } - } - } - - open fun stopSync() { - active = false - Log.d("DataSource", "${this.javaClass.simpleName} Stop") - - subscriptions.values.forEach { subscription -> - client.close(subscription.id) - subscription.typedFilters = null - } - } - - fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription { - val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE) - subscriptions = subscriptions + Pair(newSubscription.id, newSubscription) - return newSubscription - } - - fun dismissChannel(subscription: Subscription) { - client.close(subscription.id) - subscriptions = subscriptions.minus(subscription.id) - } - - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) - - fun invalidateFilters() { - bundler.invalidate { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() - } - } - - fun resetFilters() { - scope.launch(Dispatchers.IO) { resetFiltersSuspend() } - } - - private fun resetFiltersSuspend() { - Log.d("DataSource", "${this.javaClass.simpleName} resetFiltersSuspend $active") - checkNotInMainThread() - - // saves the channels that are currently active - val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } - // saves the current content to only update if it changes - val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } - - changingFilters.getAndSet(true) - - updateChannelFilters() - - // Makes sure to only send an updated filter when it actually changes. - subscriptions.values.forEach { updatedSubscription -> - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters - - val isActive = client.isActive(updatedSubscription.id) - - if (!isActive && updatedSubscriptionNewFilters != null) { - // Filter was removed from the active list - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - if (currentFilters.containsKey(updatedSubscription.id)) { - if (updatedSubscriptionNewFilters == null) { - // was active and is not active anymore, just close. - client.close(updatedSubscription.id) - } else { - // was active and is still active, check if it has changed. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { - client.close(updatedSubscription.id) - if (active) { - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } else { - // hasn't changed, does nothing. - if (active) { - client.sendFilterOnlyIfDisconnected( - updatedSubscription.id, - updatedSubscriptionNewFilters, - ) - } - } - } - } else { - if (updatedSubscriptionNewFilters == null) { - // was not active and is still not active, does nothing - } else { - // was not active and becomes active, sends the filter. - if (updatedSubscription.hasChangedFiltersFrom(currentFilters[updatedSubscription.id])) { - if (active) { - Log.d( - this@NostrDataSource.javaClass.simpleName, - "Update Filter 3 ${updatedSubscription.id} ${client.isSubscribed(clientListener)}", - ) - client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) - } - } - } - } - } - } - - changingFilters.getAndSet(false) - } - - open fun consume( - event: Event, - relay: Relay, - ) = Unit - - open fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) = Unit - - open fun markAsEOSE( - subscriptionId: String, - relay: Relay, - ) { - subscriptions[subscriptionId]?.updateEOSE( - // in case people's clock is slighly off. - TimeUtils.oneMinuteAgo(), - relay.url, - ) - } - - abstract fun updateChannelFilters() - - open fun auth( - relay: Relay, - challenge: String, - ) = Unit - - open fun notify( - relay: Relay, - description: String, - ) = Unit -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 09ce54737..2dcd65c40 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -100,8 +100,7 @@ class Relay( private var listeners = setOf() val relaySubFilter = RelaySubFilter(url, activeTypes, subs) - val inner = - SimpleClientRelay(url, socketBuilderFactory.build(url, forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) + val inner = SimpleClientRelay(url, socketBuilderFactory.build(url, forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) val brief = RelayBriefInfoCache.get(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt rename to ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt index 49185405b..aa3bdea35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/AmethystNostrDataSource.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt @@ -18,35 +18,40 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.ammolite.relays.datasources -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrDataSource +import android.util.Log +import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -abstract class AmethystNostrDataSource( - debugName: String, -) : NostrDataSource(Amethyst.instance.client, debugName) { - override fun consume( - event: Event, - relay: Relay, - ) { - LocalCache.verifyAndConsume(event, relay) +/** + * Listens to NostrClient's onEvent messages for caching purposes. + */ +class EventCollector( + val client: NostrClient, + val onEvent: (event: Event, relay: Relay) -> Unit, +) { + private val clientListener = + object : NostrClient.Listener { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + onEvent(event, relay) + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) } - override fun markAsSeenOnRelay( - eventId: String, - relay: Relay, - ) { - val note = LocalCache.getNoteIfExists(eventId) - val noteEvent = note?.event - if (noteEvent is AddressableEvent) { - LocalCache.getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) - } else { - note?.addRelay(relay) - } + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt new file mode 100644 index 000000000..4575d3059 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt @@ -0,0 +1,275 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log +import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.RelayState +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Semantically groups Nostr filters and subscriptions in data source objects that + * maintain the desired active filter with the relay. + * + * This model also allows each datasource to observe any new events from the network, + * regardless of the subscription + */ +abstract class NostrDataSource( + val client: NostrClient, +) { + private var subscriptions = SubscriptionSet() + val stats = SubscriptionStats() + + var changingFilters = AtomicBoolean() + + private var active: Boolean = false + + private val clientListener = + object : NostrClient.Listener { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + if (subscriptions.contains(subscriptionId)) { + stats.add(subscriptionId, event.kind) + + consume(event, relay) + + if (afterEOSE) { + markAsEOSE(subscriptionId, relay) + } + } + } + + override fun onEOSE( + relay: Relay, + subscriptionId: String, + ) { + if (subscriptions.contains(subscriptionId)) { + markAsEOSE(subscriptionId, relay) + } + } + + override fun onRelayStateChange( + type: RelayState, + relay: Relay, + ) {} + + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + if (success) { + markAsSeenOnRelay(eventId, relay) + } + } + + override fun onAuth( + relay: Relay, + challenge: String, + ) { + auth(relay, challenge) + } + + override fun onNotify( + relay: Relay, + description: String, + ) { + notify(relay, description) + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + stop() + client.unsubscribe(clientListener) + bundler.cancel() + } + + open fun start() { + Log.d("${this.javaClass.simpleName}", "Start") + active = true + invalidateFilters() + } + + @OptIn(DelicateCoroutinesApi::class) + open fun stop() { + active = false + Log.d("${this.javaClass.simpleName}", "Stop") + + subscriptions.forEach { subscription -> + client.close(subscription.id) + subscription.reset() + } + } + + fun getSub(subId: String) = subscriptions.get(subId) + + fun requestNewSubscription(onEOSE: ((Long, String) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) + + fun dismissSubscription(subId: String) { + getSub(subId)?.let { dismissSubscription(it) } + } + + fun dismissSubscription(subscription: Subscription) { + client.close(subscription.id) + subscription.reset() + subscriptions.remove(subscription) + } + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) + + fun invalidateFilters() { + bundler.invalidate { + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + resetFiltersSuspend() + } + } + + suspend fun resetFiltersSuspend() { + // only runs one at a time. Ignores the others + if (changingFilters.compareAndSet(false, true)) { + Log.d("${this.javaClass.simpleName}", "resetFiltersSuspend $active") + try { + resetFiltersSuspendInner() + } finally { + changingFilters.getAndSet(false) + } + } + } + + private fun resetFiltersSuspendInner() { + // saves the channels that are currently active + val activeSubscriptions = subscriptions.actives() + // saves the current content to only update if it changes + val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } + + // updates all filters + updateSubscriptions() + + // Makes sure to only send an updated filter when it actually changes. + subscriptions.forEach { newSubscriptionFilters -> + val currentFilters = currentFilters[newSubscriptionFilters.id] + updateRelaysIfNeeded(newSubscriptionFilters, currentFilters) + } + } + + fun updateRelaysIfNeeded( + updatedSubscription: Subscription, + currentFilters: List?, + ) { + val updatedSubscriptionNewFilters = updatedSubscription.typedFilters + + val isActive = client.isActive(updatedSubscription.id) + + if (!isActive && updatedSubscriptionNewFilters != null) { + // Filter was removed from the active list + // but it is supposed to be there. Send again. + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + if (currentFilters != null) { + if (updatedSubscriptionNewFilters == null) { + // was active and is not active anymore, just close. + client.close(updatedSubscription.id) + } else { + // was active and is still active, check if it has changed. + if (updatedSubscription.hasChangedFiltersFrom(currentFilters)) { + client.close(updatedSubscription.id) + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + // hasn't changed, does nothing. + // unless the relay has disconnected, then reconnect. + if (active) { + client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } + } else { + if (updatedSubscriptionNewFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the filter. + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } + } + } + + open fun consume( + event: Event, + relay: Relay, + ) = Unit + + open fun markAsSeenOnRelay( + eventId: String, + relay: Relay, + ) = Unit + + open fun markAsEOSE( + subscriptionId: String, + relay: Relay, + ) { + subscriptions[subscriptionId]?.callEose( + // in case people's clock is slighly off. + TimeUtils.oneMinuteAgo(), + relay.url, + ) + } + + open fun auth( + relay: Relay, + challenge: String, + ) = Unit + + open fun notify( + relay: Relay, + description: String, + ) = Unit + + abstract fun updateSubscriptions() +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt new file mode 100644 index 000000000..577393879 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay + +class RelayAuthenticator( + val client: NostrClient, + val authenticate: (challenge: String, relay: Relay) -> Unit, +) { + private val clientListener = + object : NostrClient.Listener { + override fun onAuth( + relay: Relay, + challenge: String, + ) { + authenticate(challenge, relay) + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt new file mode 100644 index 000000000..e14e73cad --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Listens to NostrClient's onEvent messages for caching purposes. + */ +class RelayInsertConfirmationCollector( + val client: NostrClient, + val onRelayReceived: (eventId: HexKey, relay: Relay) -> Unit, +) { + private val clientListener = + object : NostrClient.Listener { + override fun onSendResponse( + eventId: String, + success: Boolean, + message: String, + relay: Relay, + ) { + if (success) { + onRelayReceived(eventId, relay) + } + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt new file mode 100644 index 000000000..dcf4ec5d4 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay + +/** + * Listens to NostrClient's onNotify messages from the relay + */ +class RelayNotifier( + val client: NostrClient, + val notify: (message: String, relay: Relay) -> Unit, +) { + private val clientListener = + object : NostrClient.Listener { + override fun onNotify( + relay: Relay, + message: String, + ) { + notify(message, relay) + } + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt similarity index 93% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt rename to ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt index 9a4f8facd..10779816e 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt @@ -18,23 +18,28 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.ammolite.relays +package com.vitorpamplona.ammolite.relays.datasources +import com.vitorpamplona.ammolite.relays.TypedFilter import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import java.util.UUID data class Subscription( val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEOSE: ((Long, String) -> Unit)? = null, + val onEose: ((time: Long, relayUrl: String) -> Unit)? = null, ) { var typedFilters: List? = null // Inactive when null - fun updateEOSE( + fun reset() { + typedFilters = null + } + + fun callEose( time: Long, relay: String, ) { - onEOSE?.let { it(time, relay) } + onEose?.let { it(time, relay) } } fun hasChangedFiltersFrom(otherFilters: List?): Boolean { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionOrchestrator.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionOrchestrator.kt new file mode 100644 index 000000000..2c06f67d1 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionOrchestrator.kt @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log +import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Semantically groups Nostr filters and subscriptions in data source objects that + * maintain the desired active filter with the relay. + */ +abstract class SubscriptionOrchestrator( + val client: NostrClient, +) { + private val subscriptions = SubscriptionSet() + private var active: Boolean = false + private val changingFilters = AtomicBoolean() + + val stats = SubscriptionStats() + + private val clientListener = + object : NostrClient.Listener { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + if (subscriptions.contains(subscriptionId)) { + stats.add(subscriptionId, event.kind) + + if (afterEOSE) { + runAfterEOSE(subscriptionId, relay) + } + } + } + + override fun onEOSE( + relay: Relay, + subscriptionId: String, + ) { + if (subscriptions.contains(subscriptionId)) { + runAfterEOSE(subscriptionId, relay) + } + } + } + + fun runAfterEOSE( + subscriptionId: String, + relay: Relay, + ) { + subscriptions[subscriptionId]?.callEose(TimeUtils.oneMinuteAgo(), relay.url) + } + + init { + Log.d("${this.javaClass.simpleName}", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("${this.javaClass.simpleName}", "Destroy, Unsubscribe") + stop() + client.unsubscribe(clientListener) + bundler.cancel() + } + + open fun start() { + Log.d("${this.javaClass.simpleName}", "Start") + active = true + invalidateFilters() + } + + @OptIn(DelicateCoroutinesApi::class) + open fun stop() { + active = false + Log.d("${this.javaClass.simpleName}", "Stop") + + subscriptions.forEach { subscription -> + client.close(subscription.id) + subscription.reset() + } + } + + fun getSub(subId: String) = subscriptions.get(subId) + + fun requestNewSubscription(onEOSE: ((Long, String) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) + + fun dismissSubscription(subId: String) { + getSub(subId)?.let { dismissSubscription(it) } + } + + fun dismissSubscription(subscription: Subscription) { + client.close(subscription.id) + subscription.reset() + subscriptions.remove(subscription) + } + + fun isUpdatingFilters() = changingFilters.get() + + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) + + fun invalidateFilters() { + bundler.invalidate { + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + resetFiltersSuspend() + } + } + + private fun resetFiltersSuspend() { + // only runs one at a time. Ignores the others + if (changingFilters.compareAndSet(false, true)) { + Log.d("${this.javaClass.simpleName}", "resetFiltersSuspend $active") + try { + resetFiltersSuspendInner() + } finally { + changingFilters.getAndSet(false) + } + } + } + + private fun resetFiltersSuspendInner() { + // saves the channels that are currently active + val activeSubscriptions = subscriptions.actives() + // saves the current content to only update if it changes + val currentFilters = activeSubscriptions.associate { it.id to it.typedFilters } + + // updates all filters + updateSubscriptions() + + // Makes sure to only send an updated filter when it actually changes. + subscriptions.forEach { newSubscriptionFilters -> + val currentFilters = currentFilters[newSubscriptionFilters.id] + updateRelaysIfNeeded(newSubscriptionFilters, currentFilters) + } + } + + fun updateRelaysIfNeeded( + updatedSubscription: Subscription, + currentFilters: List?, + ) { + val updatedSubscriptionNewFilters = updatedSubscription.typedFilters + + val isActive = client.isActive(updatedSubscription.id) + + if (!isActive && updatedSubscriptionNewFilters != null) { + // Filter was removed from the active list + // but it is supposed to be there. Send again. + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + if (currentFilters != null) { + if (updatedSubscriptionNewFilters == null) { + // was active and is not active anymore, just close. + client.close(updatedSubscription.id) + } else { + // was active and is still active, check if it has changed. + if (updatedSubscription.hasChangedFiltersFrom(currentFilters)) { + client.close(updatedSubscription.id) + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } else { + // hasn't changed, does nothing. + // unless the relay has disconnected, then reconnect. + if (active) { + client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } + } else { + if (updatedSubscriptionNewFilters == null) { + // was not active and is still not active, does nothing + } else { + // was not active and becomes active, sends the filter. + if (active) { + client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters) + } + } + } + } + } + + abstract fun updateSubscriptions() +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt new file mode 100644 index 000000000..da9043474 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +class SubscriptionSet { + private var subscriptions = mapOf() + + fun contains(subId: String) = subscriptions.containsKey(subId) + + fun add(sub: Subscription) { + subscriptions = subscriptions + Pair(sub.id, sub) + } + + fun remove(subId: String) { + subscriptions = subscriptions.minus(subId) + } + + fun remove(sub: Subscription) = remove(sub.id) + + fun newSub(onEOSE: ((Long, String) -> Unit)? = null): Subscription = Subscription(onEose = onEOSE).also { add(it) } + + fun forEach(action: (Subscription) -> Unit) = subscriptions.values.forEach(action) + + operator fun get(subId: String) = subscriptions[subId] + + fun actives() = subscriptions.values.filter { it.typedFilters != null } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt new file mode 100644 index 000000000..58d98b78f --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionStats.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.datasources + +import android.util.Log + +class SubscriptionStats { + data class Counter( + val subscriptionId: String, + val eventKind: Int, + var counter: Int, + ) + + private var eventCounter = mapOf() + + fun eventCounterIndex( + str1: String, + str2: Int, + ): Int = 31 * str1.hashCode() + str2.hashCode() + + fun add( + subscriptionId: String, + eventKind: Int, + ) { + val key = eventCounterIndex(subscriptionId, eventKind) + val keyValue = eventCounter[key] + if (keyValue != null) { + keyValue.counter++ + } else { + eventCounter = eventCounter + Pair(key, Counter(subscriptionId, eventKind, 1)) + } + } + + fun printCounter() { + eventCounter.forEach { + Log.d( + "STATE DUMP ${this.javaClass.simpleName}", + "Received Events ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}", + ) + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt index ed2cbc9d5..e1a01af74 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt @@ -26,6 +26,8 @@ import java.util.function.BiConsumer class LargeCache { private val cache = ConcurrentSkipListMap() + fun values() = cache.values + fun get(key: K) = cache.get(key) fun remove(key: K) = cache.remove(key) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt index 02978d479..493e072cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt @@ -420,6 +420,9 @@ class SimpleClientRelay( } interface Listener { + /** + * New Event arrives from the relay. + */ fun onEvent( relay: SimpleClientRelay, subscriptionId: String, @@ -427,49 +430,76 @@ class SimpleClientRelay( afterEOSE: Boolean, ) + /** + * New EOSE command arrives for a subscription + */ fun onEOSE( relay: SimpleClientRelay, subscriptionId: String, ) + /** + * New error + */ fun onError( relay: SimpleClientRelay, subscriptionId: String, error: Error, ) + /** + * Relay is requesting authentication with the given challenge. + */ fun onAuth( relay: SimpleClientRelay, challenge: String, ) + /** + * RelayState changes + */ fun onRelayStateChange( relay: SimpleClientRelay, type: RelayState, ) + /** + * NOTIFY command has arrived. + */ fun onNotify( relay: SimpleClientRelay, description: String, ) + /** + * Relay closed the subscription + */ fun onClosed( relay: SimpleClientRelay, subscriptionId: String, message: String, ) + /** + * Triggers this before sending the event. + */ fun onBeforeSend( relay: SimpleClientRelay, event: Event, ) + /** + * Triggers after the event has been sent. + */ fun onSend( relay: SimpleClientRelay, msg: String, success: Boolean, ) + /** + * Relay accepted or rejected the event + */ fun onSendResponse( relay: SimpleClientRelay, eventId: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt index 26b560821..5c142b6b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt @@ -44,4 +44,7 @@ sealed class VerificationState { val errorMessage: String, val time: Long = TimeUtils.now(), ) : VerificationState() + + @Immutable + object Verifying : VerificationState() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt index 9c31d011f..a6c7f0004 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt @@ -27,21 +27,32 @@ import com.vitorpamplona.quartz.utils.TimeUtils class VerificationStateCache { private val cache = LruCache(200) + fun verify( + event: OtsEvent, + resolverBuilder: () -> OtsResolver, + ): VerificationState { + cache.put(event.id, VerificationState.Verifying) + return event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + } + fun cacheVerify( event: OtsEvent, resolverBuilder: () -> OtsResolver, ): VerificationState = when (val verif = cache[event.id]) { + is VerificationState.Verifying -> verif is VerificationState.Verified -> verif is VerificationState.NetworkError -> { // try again in 5 mins if (verif.time < TimeUtils.fiveMinutesAgo()) { event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } } else { - verif + verify(event, resolverBuilder) } } is VerificationState.Error -> verif - else -> event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } + else -> { + verify(event, resolverBuilder) + } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt index d44ca0a57..234293f3b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/content/ContentHashTags.kt @@ -28,6 +28,8 @@ fun findHashtags( content: String, output: MutableSet = mutableSetOf(), ): List { + if (content.isBlank()) return emptyList() + val matcher = hashtagSearch.matcher(content) while (matcher.find()) { try { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt index 64753dd76..660a2197c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.ALT_DESCRIPTION -import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.KIND import com.vitorpamplona.quartz.utils.TimeUtils class DraftBuilder { @@ -41,8 +39,8 @@ class DraftBuilder { ) { signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent -> val template = - eventTemplate(KIND, encryptedContent, createdAt) { - alt(ALT_DESCRIPTION) + eventTemplate(DraftEvent.KIND, encryptedContent, createdAt) { + alt(DraftEvent.ALT_DESCRIPTION) dTag(dTag) kind(draft.kind)